Compare commits
4 Commits
aa2df560b7
...
f9ffb7bd1c
| Author | SHA1 | Date |
|---|---|---|
|
|
f9ffb7bd1c | |
|
|
e94c64eebd | |
|
|
007ff9fff2 | |
|
|
656f0c524d |
|
|
@ -26,6 +26,9 @@ private/
|
|||
# Real laboratory captures and decoded artifacts are sensitive and large.
|
||||
captures/
|
||||
sessions/
|
||||
# The host runtime package shares the domain name but is source, not evidence.
|
||||
!src/k1link/sessions/
|
||||
!src/k1link/sessions/**/*.py
|
||||
artifacts/raw/
|
||||
artifacts/decoded/
|
||||
*.pcap
|
||||
|
|
|
|||
69
README.md
69
README.md
|
|
@ -10,9 +10,11 @@ The first proven hardware vertical is the XGRIDS/LixelKity K1 plugin. On firmwar
|
|||
3.0.2 the host provisions the scanner onto an existing LAN without LixelGO,
|
||||
connects to its MQTT broker, persists each raw frame before preview work, decodes
|
||||
point cloud and pose, and renders the real cloud plus trajectory through an
|
||||
embedded self-hosted Rerun Web Viewer. Capture files are `fsync`ed on clean
|
||||
close; per-frame power-loss durability is not claimed. The former Foxglove
|
||||
bridge remains only as a legacy regression module.
|
||||
embedded self-hosted Rerun Web Viewer. Native MQTT persistence uses bounded
|
||||
group commit (at most 0.5 seconds, 4 MiB or 32 messages), and camera archives
|
||||
commit complete fMP4 segments before their index rows. These are explicit
|
||||
crash-RPO bounds, not a zero-loss or disk-replication claim. The former
|
||||
Foxglove bridge remains only as a legacy regression module.
|
||||
|
||||
The repository is intentionally migrating in stages. The current `src/k1link`
|
||||
package is the compatibility implementation of the first plugin path; vendor
|
||||
|
|
@ -124,19 +126,24 @@ revision or content hash. Publishing/vendoring those packages or enforcing an
|
|||
immutable donor revision remains a packaging and CI prerequisite.
|
||||
|
||||
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
||||
inside the Mission Core shell. It can open a compatible RRD file over HTTP(S) or a
|
||||
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
|
||||
use an external hosted viewer UI. Dynamic point-cloud and camera source
|
||||
composition, host-owned window layout and the current live-only timeline contract
|
||||
are fixed in [`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
|
||||
inside the Mission Core shell. It can open a compatible RRD over same-origin
|
||||
HTTP or a Rerun gRPC/proxy source such as
|
||||
`rerun+http://127.0.0.1:9876/proxy`; no external hosted viewer UI is used.
|
||||
Dynamic point-cloud/camera composition and the live source contract are fixed in
|
||||
[`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
|
||||
The durable session catalog, recorded `session_time` scrubber and versioned
|
||||
workspace-layout profile are fixed in
|
||||
[`ADR 0008`](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md).
|
||||
|
||||
The first K1 live session or replay in a `k1link serve` process creates one local
|
||||
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
|
||||
the resulting URL through control-plane state. Later sessions reset their
|
||||
session-local scene and metrics and reuse that process-wide stream; this avoids
|
||||
restarting the native listener while the embedded browser remains connected.
|
||||
Unless an operator has entered a manual source, the React application assigns
|
||||
that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first
|
||||
The first K1 live session or adapter file-replay (`.k1mqtt`/reviewed TSV) in a
|
||||
`k1link serve` process creates one local Rerun `RecordingStream`, starts its
|
||||
gRPC/proxy server on TCP 9876 and publishes the resulting URL through
|
||||
control-plane state. Later live/file-replay sessions reset their session-local
|
||||
scene and metrics and reuse that process-wide stream. Saved observation sessions
|
||||
do not reuse this listener: they open a private digest-bound RRD generation over
|
||||
same-origin HTTP through Rerun's native incremental receiver. Unless an operator
|
||||
has entered a manual source, the React application assigns the applicable source
|
||||
to the embedded viewer. The complete live runtime path is K1 MQTT → raw-first
|
||||
evidence capture → bounded latest-wins preview queue → explicitly injected K1
|
||||
protobuf/LZ4 normalizer → transport-neutral decoded local views → Rerun
|
||||
`Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. Rerun does
|
||||
|
|
@ -146,15 +153,21 @@ portable Plugin SDK wire envelopes.
|
|||
The default Rerun blueprint shows a 12-second sliding accumulation of real point
|
||||
frames. Product controls are connected for point size, intensity/height/distance
|
||||
or available RGB coloring, Turbo/Viridis/Plasma/grayscale/custom palettes,
|
||||
point and trajectory visibility, and the scene grid. Projection, custom
|
||||
timeline transport and saved layout remain later product work. No synthetic
|
||||
point cloud, trajectory, camera frame or latency value is generated.
|
||||
point and trajectory visibility, and the scene grid. The first disk action saves
|
||||
and restores the versioned spatial layout without mutating sensor evidence.
|
||||
Saved native point/pose sessions are materialized losslessly into private,
|
||||
digest-bound RRD recordings and can be played, paused and scrubbed on a
|
||||
zero-based `session_time` timeline. No synthetic point cloud, trajectory,
|
||||
camera frame or latency value is generated.
|
||||
|
||||
A powered-device checkpoint passed 80 real MQTT messages through the current
|
||||
Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last
|
||||
cloud and zero decode errors. The later RTSP camera preview is not yet wired into
|
||||
the Rerun/runtime path. Rerun `capture_time` is the Mac receive timestamp, not a
|
||||
proven K1 sensor timestamp or photon-to-screen measurement.
|
||||
cloud and zero decode errors. The later RTSP camera preview is available through
|
||||
the generic floating observation windows and new acquisitions archive its fMP4
|
||||
segments independently of browser delivery. Historical sessions recorded before
|
||||
that archive contract contain no video. Rerun `capture_time` and the camera
|
||||
index use Mac receive/arrival timestamps, not proven K1 sensor timestamps or a
|
||||
photon-to-screen measurement.
|
||||
|
||||
The old Foxglove implementation is retained only in
|
||||
`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current
|
||||
|
|
@ -162,6 +175,8 @@ live/replay runtime does not start it or use TCP 8765. The
|
|||
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
|
||||
its timing/security boundaries; the frontend contract is documented in
|
||||
[`apps/control-station/README.md`](apps/control-station/README.md).
|
||||
The host storage layout, recovery rules, replay API and operator path are in
|
||||
[`docs/09_OBSERVATION_SESSIONS.md`](docs/09_OBSERVATION_SESSIONS.md).
|
||||
|
||||
The FastAPI application and credential endpoint bind to loopback, but the Rerun
|
||||
gRPC server currently binds TCP 9876 on all network interfaces even though its
|
||||
|
|
@ -214,9 +229,15 @@ present.
|
|||
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
|
||||
- [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
|
||||
- [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md)
|
||||
- [Owner-controlled LixelGO/iPhone observation](docs/08_LIXELGO_IPHONE_OBSERVATION.md)
|
||||
- [Observation sessions, playback and workspace layout](docs/09_OBSERVATION_SESSIONS.md)
|
||||
- [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md)
|
||||
- [Device plugin UI and runtime boundary](docs/adr/0003-device-plugin-ui-and-runtime-boundary.md)
|
||||
- [Plugin SDK v0alpha2 and experimental device lifecycle](docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md)
|
||||
- [Owner-controlled LixelGO observation decision](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md)
|
||||
- [Vendor-neutral live observation sources](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md)
|
||||
- [K1 camera preview copy-remux gateway](docs/adr/0007-k1-camera-preview-copy-remux-gateway.md)
|
||||
- [Durable observation sessions and workspace layout](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md)
|
||||
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||
- [Reference input provenance](docs/reference/README.md)
|
||||
|
|
@ -240,6 +261,6 @@ reviewed step. Random writes, fuzzing, brute force, firmware operations,
|
|||
destructive file access and credential guessing remain out of scope.
|
||||
|
||||
Real captures, projects, router metadata, serials, credentials, maps, images,
|
||||
and logs are ignored by normal Git. Redacted manifests and SHA-256 inventories
|
||||
are committed; encrypted artifact storage will be selected only when real data
|
||||
exists.
|
||||
and logs under `.runtime/`, canonical evidence roots and legacy `sessions/` are
|
||||
ignored by normal Git. Redacted manifests and SHA-256 inventories are committed;
|
||||
encrypted/replicated artifact storage remains a deployment decision.
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ device adapter, но структура интерфейса от него не
|
|||
|
||||
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
|
||||
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
|
||||
переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам
|
||||
создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun
|
||||
потока или совместимой RRD-записи.
|
||||
переход во внешний облачный интерфейс. Для K1 live и adapter file-replay backend
|
||||
сам создаёт Rerun gRPC/proxy source. Saved observation sessions открывают
|
||||
проверенную digest-bound RRD generation через same-origin HTTP; ручной адрес
|
||||
нужен только для другого совместимого Rerun source.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
|
|
@ -20,11 +21,13 @@ device adapter, но структура интерфейса от него не
|
|||
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
||||
| Автоматический MQTT → Rerun | Реализован | Первый live/replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие сессии сбрасывают сцену и метрики и переиспользуют его. |
|
||||
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
|
||||
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
|
||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория и сетка. Проекция, собственный timeline и сохранённые layout-профили ещё не подключены. |
|
||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
|
||||
| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v6, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
|
||||
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но реальная архивная K1 camera-session ещё не прошла physical acceptance. |
|
||||
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
|
||||
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
|
||||
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
|
||||
|
||||
Приложение не генерирует демонстрационное облако, траекторию, кадры или
|
||||
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
|
||||
|
|
@ -48,9 +51,16 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/*
|
|||
+ plugin-scoped WebSocket events
|
||||
FastAPI на 127.0.0.1:8000
|
||||
CoreBluetooth + live/replay runtime
|
||||
|
||||
sealed/recovered observation session
|
||||
└── SQLite catalog + bounded background preparation
|
||||
└── atomic RRD cache v6 + recorded-media manifest v2
|
||||
└── generation-bound same-origin HTTP
|
||||
└── aggregate admission
|
||||
└── Rerun native receiver + recorded fMP4 player
|
||||
```
|
||||
|
||||
В момент готовности `RerunBridge` backend публикует адрес вида
|
||||
В момент готовности live/file-replay `RerunBridge` backend публикует адрес вида
|
||||
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
|
||||
если оператор не указал ручной source. После остановки приёма URL и встроенный
|
||||
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
|
||||
|
|
@ -65,7 +75,7 @@ viewer остаются активны, а следующая сессия сб
|
|||
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
|
||||
всех функциональных модулей:
|
||||
|
||||
1. `AppHeader` — марка NODEDC MISSION CORE, выбор архитектурного раздела и состояние
|
||||
1. `AppHeader` — марка NODE DC, выбор архитектурного раздела и состояние
|
||||
локального backend.
|
||||
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
|
||||
выбранного раздела.
|
||||
|
|
@ -78,8 +88,10 @@ Shell собран из локальных NODE.DC UI packages и сохраня
|
|||
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
|
||||
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
|
||||
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
|
||||
Rerun Blueprint. Кнопки собственного timeline, смена 2D/3D/карты и сохранение
|
||||
layout пока остаются интерфейсным контрактом.
|
||||
Rerun Blueprint. Host timeline управляет сохранённым `session_time`, а spatial
|
||||
layout сохраняет display settings, tool windows и source-window geometry через
|
||||
revisioned API. Смена 2D/3D/карты и семантические слои пока остаются
|
||||
интерфейсным контрактом.
|
||||
|
||||
## Архитектурные разделы
|
||||
|
||||
|
|
@ -139,10 +151,15 @@ cd apps/control-station
|
|||
npm run dev
|
||||
```
|
||||
|
||||
Vite слушает `http://127.0.0.1:5173` и проксирует весь `/api` (включая WebSocket) на
|
||||
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
|
||||
`VITE_API_TARGET`. Preview production-сборки запускается командой
|
||||
`npm run preview` на `http://127.0.0.1:4173`.
|
||||
Для полного live-viewer контура Vite должен открыться на
|
||||
`http://127.0.0.1:5173`: только 5173, production preview 4173 и backend 8000
|
||||
входят в текущий Rerun CORS allowlist. Если Vite сообщает fallback на 5174 или
|
||||
выше, освободите 5173 и перезапустите `npm run dev`; REST/WebSocket proxy на
|
||||
fallback-порту может работать, но прямой browser → Rerun 9876 будет отклонён.
|
||||
Vite проксирует весь `/api` (включая WebSocket) на `http://127.0.0.1:8000`.
|
||||
Другой локальный backend можно указать переменной `VITE_API_TARGET`. Preview
|
||||
production-сборки запускается командой `npm run preview` на
|
||||
`http://127.0.0.1:4173`.
|
||||
|
||||
## Операторский путь для текущего K1 adapter
|
||||
|
||||
|
|
@ -167,6 +184,9 @@ Vite слушает `http://127.0.0.1:5173` и проксирует весь `/a
|
|||
9. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
|
||||
частота, число точек, задержка и пропуски preview появятся после прихода
|
||||
сообщений K1.
|
||||
10. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
|
||||
состояния **Готово**, выбрать запись и использовать host timeline. Evidence
|
||||
сохраняется автоматически; disk action сохраняет только workspace layout.
|
||||
|
||||
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
|
||||
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
|
||||
|
|
@ -203,9 +223,12 @@ Plasma, grayscale и custom, накопление `0–120` секунд, а т
|
|||
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
|
||||
а не готовую семантическую классификацию.
|
||||
|
||||
Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL
|
||||
пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только
|
||||
в памяти текущей страницы и не записывается на диск.
|
||||
Для saved session host timeline вызывает Rerun play/pause/seek только после
|
||||
полного admission. Accumulation и display settings применяются через отдельный
|
||||
маленький blueprint RRD и не заменяют основную запись. Versioned
|
||||
`observation.spatial` layout сохраняется на host с optimistic revision; он не
|
||||
содержит sensor evidence. Переключатель 2D/3D/карты и семантические слои пока не
|
||||
вызывают готовый presentation backend.
|
||||
|
||||
## Контракт локального API
|
||||
|
||||
|
|
@ -216,6 +239,12 @@ Custom timeline, переключатель 2D/3D/карты, семантиче
|
|||
| `GET` | `/api/v1/device-models` | Backend-каталог моделей из валидированных manifests; UI-каталог текущей сборки формируется отдельным static composition root. |
|
||||
| `POST` | `/api/v1/device-plugins/{pluginId}/actions/{actionId}` | Namespaced действие через host allowlist; тело `{ "input": { ... } }`. |
|
||||
| `WS` | `/api/v1/device-plugins/{pluginId}/events` | Plugin-scoped snapshots с `pluginId` и монотонным `sequence`. |
|
||||
| `GET` | `/api/v1/observation-sessions` | Последние cataloged sessions и authoritative preparation state. |
|
||||
| `POST` | `/api/v1/observation-sessions/{id}/replay` | Verified replay launch либо HTTP 202 preparation handle. |
|
||||
| `GET` | `/api/v1/observation-sessions/{id}/recording-preparation` | Exact-job polling с `If-Match`. |
|
||||
| `GET` | `/api/v1/observation-sessions/{id}/recording.rrd` | Immutable generation-bound RRD; arbitrary chunked `send_rrd` не используется. |
|
||||
| `GET` | `/api/v1/observation-sessions/{id}/media/{artifact}/...` | Manifest/init/segments записанных камер по opaque identifiers. |
|
||||
| `GET`, `PUT` | `/api/v1/workspace-layouts/observation.spatial` | Versioned host layout с optimistic revision. |
|
||||
|
||||
Frontend XGRIDS-плагина использует только v1alpha namespaced routes. Старые
|
||||
`/api/state`, `/api/events`, `/api/ble/scan`, `/api/connect`, `/api/session/*` и
|
||||
|
|
@ -257,7 +286,12 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
|
|||
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
|
||||
| `src/device-plugins/xgrids-k1/` | Реальный K1 BLE/Wi-Fi/live/replay UI, client и compatibility mapper. |
|
||||
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
|
||||
| `src/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. |
|
||||
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
|
||||
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
|
||||
| `src/components/RecordedFmp4Player.tsx` | Проверенный generation-bound MSE playback архивных камер. |
|
||||
| `src/core/observation/sessionArchive.ts` | Строгий wire contract catalog/preparation/replay/media API. |
|
||||
| `src/core/observation/recordedSessionAdmission.ts` | Общий RRD/camera gate до публикации recorded workspace. |
|
||||
| `src/core/observation/workspaceLayout.ts` | Versioned spatial layout и optimistic revision. |
|
||||
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
|
||||
| `src/presentation.ts` | Vendor-neutral подписи lifecycle и форматирование нормализованных метрик. |
|
||||
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
|
||||
|
|
@ -277,14 +311,17 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
|
|||
случайные GATT writes и автоматические повторы запрещены.
|
||||
- MQTT live/replay не публикует команды устройству. Запуск и остановка
|
||||
физического сканирования остаются за кнопкой K1.
|
||||
- Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При
|
||||
перегрузке preview может быть отброшен, raw evidence сохраняется.
|
||||
- Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
|
||||
формируют disposable preview. При перегрузке preview может быть отброшен,
|
||||
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,
|
||||
camera durability — текущий незавершённый fragment RPO; это не zero-loss claim.
|
||||
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
|
||||
накопления viewer использует session-local `stream_time`. `capture_time` — не
|
||||
доказанный timestamp сенсора K1 и не photon-to-screen latency.
|
||||
- Панорамный камерный поток в наблюдавшихся MQTT report topics отсутствует;
|
||||
текущая Rerun-сцена содержит только облако точек и позу/траекторию, а
|
||||
операционные метрики отображаются внешней оболочкой Mission Core.
|
||||
- `sessions/` игнорируется Git и может содержать адреса, идентификаторы,
|
||||
траекторию и карту помещения. В репозиторий попадают только redacted manifests
|
||||
и безопасная документация.
|
||||
- Панорамный камерный поток в MQTT report topics отсутствует; отдельные
|
||||
left/right RTSP preview доступны через generic camera windows. LiDAR и camera
|
||||
сейчас синхронизированы только по host arrival, не по доказанным sensor clocks.
|
||||
- `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и
|
||||
могут содержать адреса, изображения, идентификаторы, траекторию и карту
|
||||
помещения. В репозиторий попадают только код, тесты, redacted manifests и
|
||||
безопасная документация.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
"": {
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
|
|
@ -834,6 +836,18 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodedc/tokens": {
|
||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"link": true
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
|
||||
if (manifest.version !== "0.34.1") {
|
||||
throw new Error(
|
||||
`Refusing to patch @rerun-io/web-viewer ${manifest.version}; audit the new vendor lifecycle first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const patches = [
|
||||
{
|
||||
label: "compiled watchdog",
|
||||
path: resolve(packageRoot, "index.js"),
|
||||
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
|
||||
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
|
||||
},
|
||||
{
|
||||
label: "source watchdog",
|
||||
path: resolve(packageRoot, "index.ts"),
|
||||
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
|
||||
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
|
||||
},
|
||||
{
|
||||
label: "compiled singleton global listener",
|
||||
path: resolve(packageRoot, "index.js"),
|
||||
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
|
||||
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
|
||||
},
|
||||
{
|
||||
label: "source singleton global listener",
|
||||
path: resolve(packageRoot, "index.ts"),
|
||||
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
|
||||
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const patch of patches) {
|
||||
const source = readFileSync(patch.path, "utf8");
|
||||
if (source.includes(patch.after)) continue;
|
||||
if (!source.includes(patch.before)) {
|
||||
throw new Error(`Vendor ${patch.label} patch no longer matches ${patch.path}.`);
|
||||
}
|
||||
writeFileSync(patch.path, source.replace(patch.before, patch.after));
|
||||
}
|
||||
|
||||
for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "index.ts")]) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
const keyupListenerCount = source.split('window.addEventListener("keyup"').length - 1;
|
||||
if (!source.includes("let globalEventListenersInstalled = false;") || keyupListenerCount !== 1) {
|
||||
throw new Error(`Vendor singleton global listener verification failed for ${path}.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Patched @rerun-io/web-viewer 0.34.1 watchdog teardown and singleton global keyup listener.",
|
||||
);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
|
|
@ -26,10 +26,25 @@ import {
|
|||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||
import {
|
||||
createLatestAsyncCommitter,
|
||||
type LatestAsyncCommitter,
|
||||
} from "./core/runtime/latestAsyncCommitter";
|
||||
import { useObservationLayout } from "./core/observation/useObservationLayout";
|
||||
import { recordedObservationSources } from "./core/observation/recordedObservationSources";
|
||||
import type { ObservationSessionReplayLaunch } from "./core/observation/sessionArchive";
|
||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||
import {
|
||||
OBSERVATION_WORKSPACE_ID,
|
||||
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
type ObservationToolWindowId,
|
||||
type ObservationWorkspaceLayoutProfile,
|
||||
} from "./core/observation/workspaceLayout";
|
||||
import {
|
||||
rootById,
|
||||
roots,
|
||||
|
|
@ -48,7 +63,10 @@ import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
|||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import "./styles/scene-windows.css";
|
||||
|
||||
type SceneToolWindowId = "sources" | "display" | "layers" | "layout";
|
||||
type SceneToolWindowId = ObservationToolWindowId;
|
||||
|
||||
const sceneToolWindowIds: readonly SceneToolWindowId[] = ["sources", "display", "layers"];
|
||||
const viewerSettingsQuietPeriodMs = 750;
|
||||
|
||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||
|
|
@ -116,28 +134,117 @@ export default function App() {
|
|||
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||
const [sourceDraft, setSourceDraft] = useState("");
|
||||
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
|
||||
const [displayWindowOpen, setDisplayWindowOpen] = useState(false);
|
||||
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
|
||||
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
|
||||
const [layoutWindowOpen, setLayoutWindowOpen] = useState(false);
|
||||
const [layoutName, setLayoutName] = useState("Операторская сцена");
|
||||
const [layoutDraftSaved, setLayoutDraftSaved] = useState(false);
|
||||
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
|
||||
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const appliedProfileKeyRef = useRef<string | null>(null);
|
||||
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
||||
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
||||
const replayActiveRef = useRef(false);
|
||||
const sceneSettingsCommitterActiveRef = useRef(true);
|
||||
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
|
||||
|
||||
const currentRoot = rootById(activeRoot);
|
||||
const activeDefinition = workspaceById(workspace.activeView);
|
||||
const rootWorkspaces = workspacesForRoot(activeRoot);
|
||||
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
||||
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
|
||||
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
|
||||
const observationLayout = useObservationLayout(
|
||||
runtime.state?.observationSources ?? [],
|
||||
runtime.setObservationSourceActive,
|
||||
const effectiveSourceUrl = replayTransitioning ? "" : sourceUrl || automaticSourceUrl;
|
||||
const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl);
|
||||
const recordedSessionAdmission = useRecordedSessionAdmission(
|
||||
replayActive ? recordedReplay : null,
|
||||
);
|
||||
const focusedObservationSource = runtime.state?.observationSources?.find(
|
||||
const displayPaletteOptions = replayActive
|
||||
? paletteOptions.map((option) => {
|
||||
if (option.value === "turbo") {
|
||||
return {
|
||||
...option,
|
||||
label: "Цвет записи",
|
||||
description: "Вернуть цвета, сохранённые внутри RRD",
|
||||
};
|
||||
}
|
||||
return option.value === "custom" ? option : { ...option, disabled: true };
|
||||
})
|
||||
: paletteOptions;
|
||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||
replayActiveRef.current = replayActive;
|
||||
|
||||
if (!sceneSettingsCommitterRef.current) {
|
||||
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
|
||||
commit: (settings) => replayActiveRef.current
|
||||
? Promise.resolve(true)
|
||||
: runtimeUpdateViewerSettingsRef.current(toViewerSettings(settings)),
|
||||
onSettled: ({ value, applied, superseded }) => {
|
||||
if (!sceneSettingsCommitterActiveRef.current) return;
|
||||
if (applied) confirmedSceneSettingsRef.current = value;
|
||||
if (superseded) return;
|
||||
|
||||
const next = applied ? value : confirmedSceneSettingsRef.current;
|
||||
sceneSettingsRef.current = next;
|
||||
setSceneSettings(next);
|
||||
if (!applied && displayDraftRef.current === value) {
|
||||
displayDraftRef.current = next;
|
||||
setDisplayDraft(next);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
const replaySources = useMemo(
|
||||
() => recordedObservationSources(recordedReplay),
|
||||
[recordedReplay],
|
||||
);
|
||||
const activeObservationSources = replayActive
|
||||
? replaySources
|
||||
: runtime.state?.observationSources ?? [];
|
||||
const activeRuntimeState = useMemo(() => {
|
||||
if (!replayActive || !recordedReplay) return runtime.state;
|
||||
return {
|
||||
...(runtime.state ?? { phase: "replaying" as const }),
|
||||
phase: "replaying" as const,
|
||||
sourceMode: "replay" as const,
|
||||
spatialSource: {
|
||||
id: "recorded.spatial.primary",
|
||||
url: recordedReplay.sourceUrl,
|
||||
label: "Сохранённая пространственная сцена",
|
||||
kind: "rrd" as const,
|
||||
},
|
||||
observationSources: replaySources,
|
||||
observationTimeline: {
|
||||
mode: "recorded" as const,
|
||||
seekable: true,
|
||||
sessionRecording: true,
|
||||
synchronization: "host-arrival-best-effort" as const,
|
||||
range: {
|
||||
startSeconds: recordedReplay.timelineStartSeconds,
|
||||
endSeconds: recordedReplay.timelineEndSeconds,
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [recordedReplay, replayActive, replaySources, runtime.state]);
|
||||
const viewerSettingsTargetIdentity = [
|
||||
runtime.state?.activeDevice?.pluginId,
|
||||
runtime.state?.activeDevice?.modelId,
|
||||
runtime.state?.activeDevice?.instanceId,
|
||||
runtime.state?.deviceSession?.sessionId,
|
||||
runtime.state?.deviceSession?.deviceId,
|
||||
runtime.state?.acquisition?.acquisitionId,
|
||||
].filter(Boolean).join(":") || "local-runtime";
|
||||
const observationLayout = useObservationLayout(
|
||||
activeObservationSources,
|
||||
replayActive ? undefined : runtime.setObservationSourceActive,
|
||||
);
|
||||
const workspaceLayoutProfile = useWorkspaceLayoutProfile();
|
||||
const focusedObservationSource = activeObservationSources.find(
|
||||
(source) => source.id === observationLayout.focusedSourceId,
|
||||
);
|
||||
const observationFullscreenActive = Boolean(
|
||||
|
|
@ -148,12 +255,80 @@ export default function App() {
|
|||
|
||||
useEffect(() => {
|
||||
const remote = runtime.state?.viewerSettings;
|
||||
if (!remote) return;
|
||||
setSceneSettings((current) => mergeViewerSettings(current, remote));
|
||||
if (!displayWindowOpen) {
|
||||
setDisplayDraft((current) => mergeViewerSettings(current, remote));
|
||||
if (
|
||||
!remote ||
|
||||
workspaceLayoutProfile.profile ||
|
||||
sceneSettingsCommitterRef.current?.isBusy()
|
||||
) return;
|
||||
const merged = mergeViewerSettings(sceneSettingsRef.current, remote);
|
||||
sceneSettingsRef.current = merged;
|
||||
confirmedSceneSettingsRef.current = merged;
|
||||
setSceneSettings(merged);
|
||||
if (viewerSettingsCommitTimerRef.current === null) {
|
||||
displayDraftRef.current = merged;
|
||||
setDisplayDraft(merged);
|
||||
}
|
||||
}, [runtime.state?.viewerSettings, displayWindowOpen]);
|
||||
}, [runtime.state?.viewerSettings, workspaceLayoutProfile.profile]);
|
||||
|
||||
useEffect(() => {
|
||||
const profile = workspaceLayoutProfile.profile;
|
||||
if (!profile) return;
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
}
|
||||
sceneSettingsRef.current = profile.sceneSettings;
|
||||
displayDraftRef.current = profile.sceneSettings;
|
||||
confirmedSceneSettingsRef.current = profile.sceneSettings;
|
||||
setSceneSettings(profile.sceneSettings);
|
||||
setDisplayDraft(profile.sceneSettings);
|
||||
setSourceWindowOpen(profile.toolWindows.sourcesOpen);
|
||||
setDisplayWindowOpen(profile.toolWindows.displayOpen);
|
||||
setLayerInspectorOpen(profile.toolWindows.layersOpen);
|
||||
setSceneWindowOrder(profile.toolWindows.order.filter((windowId) => {
|
||||
if (windowId === "sources") return profile.toolWindows.sourcesOpen;
|
||||
if (windowId === "display") return profile.toolWindows.displayOpen;
|
||||
return profile.toolWindows.layersOpen;
|
||||
}));
|
||||
observationLayout.restore(profile);
|
||||
}, [observationLayout.restore, workspaceLayoutProfile.profile]);
|
||||
|
||||
useEffect(() => {
|
||||
sceneSettingsCommitterActiveRef.current = true;
|
||||
return () => {
|
||||
sceneSettingsCommitterActiveRef.current = false;
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const profile = workspaceLayoutProfile.profile;
|
||||
const applicationKey = profile
|
||||
? `${profile.revision}:${viewerSettingsTargetIdentity}`
|
||||
: null;
|
||||
if (
|
||||
!profile ||
|
||||
runtime.backendStatus !== "online" ||
|
||||
!applicationKey ||
|
||||
appliedProfileKeyRef.current === applicationKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
appliedProfileKeyRef.current = applicationKey;
|
||||
void runtime.updateViewerSettings(toViewerSettings(profile.sceneSettings)).then((applied) => {
|
||||
if (!applied && appliedProfileKeyRef.current === applicationKey) {
|
||||
appliedProfileKeyRef.current = null;
|
||||
}
|
||||
});
|
||||
}, [
|
||||
runtime.backendStatus,
|
||||
runtime.updateViewerSettings,
|
||||
viewerSettingsTargetIdentity,
|
||||
workspaceLayoutProfile.profile,
|
||||
]);
|
||||
|
||||
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
setSceneWindowOrder((current) => [
|
||||
|
|
@ -166,7 +341,6 @@ export default function App() {
|
|||
if (windowId === "sources") setSourceWindowOpen(false);
|
||||
if (windowId === "display") setDisplayWindowOpen(false);
|
||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||
if (windowId === "layout") setLayoutWindowOpen(false);
|
||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||
}, []);
|
||||
|
||||
|
|
@ -204,8 +378,41 @@ export default function App() {
|
|||
activateSceneWindow("sources");
|
||||
};
|
||||
|
||||
const commitDisplaySettings = useCallback((next: SceneSettings) => {
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
}
|
||||
displayDraftRef.current = next;
|
||||
setDisplayDraft(next);
|
||||
sceneSettingsCommitterRef.current?.enqueue(next);
|
||||
}, []);
|
||||
|
||||
const stageDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
||||
const next = { ...displayDraftRef.current, ...patch };
|
||||
displayDraftRef.current = next;
|
||||
setDisplayDraft(next);
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
}
|
||||
viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
||||
}, viewerSettingsQuietPeriodMs);
|
||||
}, []);
|
||||
|
||||
const flushDisplaySettings = useCallback(() => {
|
||||
if (viewerSettingsCommitTimerRef.current === null) return;
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
||||
}, []);
|
||||
|
||||
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
||||
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
|
||||
}, [commitDisplaySettings]);
|
||||
|
||||
const openDisplay = () => {
|
||||
setDisplayDraft(sceneSettings);
|
||||
setDisplayWindowOpen(true);
|
||||
activateSceneWindow("display");
|
||||
};
|
||||
|
|
@ -215,28 +422,90 @@ export default function App() {
|
|||
activateSceneWindow("layers");
|
||||
};
|
||||
|
||||
const openLayout = () => {
|
||||
setLayoutDraftSaved(false);
|
||||
setLayoutWindowOpen(true);
|
||||
activateSceneWindow("layout");
|
||||
};
|
||||
const beginRecordedReplaySwitch = useCallback(async () => {
|
||||
// useObservationSessions calls this only after backend preparation has
|
||||
// produced a validated launch descriptor. Keep the old scene mounted
|
||||
// before this point; now perform one controlled receiver teardown before
|
||||
// accepting the already-ready archive.
|
||||
setReplayTransitioning(true);
|
||||
setRecordedReplay(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
await new Promise<void>((resolve) => {
|
||||
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyDisplaySettings = async () => {
|
||||
const applied = await runtime.updateViewerSettings(toViewerSettings(displayDraft));
|
||||
if (applied) setSceneSettings(displayDraft);
|
||||
};
|
||||
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
|
||||
setRecordedReplay(launch);
|
||||
setSourceUrl(launch.sourceUrl);
|
||||
setSourceDraft(launch.sourceUrl);
|
||||
setReplayTransitioning(false);
|
||||
}, []);
|
||||
|
||||
const applyScenePatch = async (patch: Partial<SceneSettings>) => {
|
||||
const previous = sceneSettings;
|
||||
const next = { ...sceneSettings, ...patch };
|
||||
setSceneSettings(next);
|
||||
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next));
|
||||
const applied = await runtime.updateViewerSettings(toViewerSettings(next));
|
||||
if (!applied) {
|
||||
setSceneSettings(previous);
|
||||
if (!displayWindowOpen) setDisplayDraft(previous);
|
||||
const settleRecordedReplaySwitch = useCallback((outcome: "accepted" | "error" | "cancelled") => {
|
||||
if (outcome !== "accepted") setReplayTransitioning(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// ObservationSessionSelect owns the cancellable request and unmounts when
|
||||
// the operator leaves the spatial workspace. Its unmount cannot safely
|
||||
// call back into this owner, so clear the transient blanking state here.
|
||||
// Returning to Observation then starts from a deterministic idle source
|
||||
// instead of an orphaned `replayTransitioning=true` state.
|
||||
if (activeDefinition?.kind !== "spatial") setReplayTransitioning(false);
|
||||
}, [activeDefinition?.kind]);
|
||||
|
||||
const applyScenePatch = (patch: Partial<SceneSettings>) => commitDisplayPatch(patch);
|
||||
|
||||
const saveWorkspaceLayout = useCallback(async () => {
|
||||
flushDisplaySettings();
|
||||
await sceneSettingsCommitterRef.current?.waitForIdle();
|
||||
|
||||
const layout = observationLayout.snapshot();
|
||||
if (!layout) {
|
||||
setLayoutSaveNotice("Сцена ещё не измерила рабочую область.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
const openWindowOrder = sceneWindowOrder.filter((windowId) => {
|
||||
if (windowId === "sources") return sourceWindowOpen;
|
||||
if (windowId === "display") return displayWindowOpen;
|
||||
return layerInspectorOpen;
|
||||
});
|
||||
const completeWindowOrder = [
|
||||
...sceneToolWindowIds.filter((windowId) => !openWindowOrder.includes(windowId)),
|
||||
...openWindowOrder,
|
||||
];
|
||||
const draft: ObservationWorkspaceLayoutProfile = {
|
||||
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
revision: workspaceLayoutProfile.profile?.revision ?? 0,
|
||||
workspaceId: OBSERVATION_WORKSPACE_ID,
|
||||
sceneSettings: sceneSettingsRef.current,
|
||||
toolWindows: {
|
||||
sourcesOpen: sourceWindowOpen,
|
||||
displayOpen: displayWindowOpen,
|
||||
layersOpen: layerInspectorOpen,
|
||||
order: completeWindowOrder,
|
||||
},
|
||||
...layout,
|
||||
};
|
||||
const saved = await workspaceLayoutProfile.save(draft);
|
||||
setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error);
|
||||
}, [
|
||||
displayWindowOpen,
|
||||
flushDisplaySettings,
|
||||
layerInspectorOpen,
|
||||
observationLayout.snapshot,
|
||||
sceneWindowOrder,
|
||||
sourceWindowOpen,
|
||||
workspaceLayoutProfile,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutSaveNotice || layoutSaveNotice !== "Компоновка сохранена") return;
|
||||
const timer = window.setTimeout(() => setLayoutSaveNotice(null), 2600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
|
|
@ -251,25 +520,27 @@ export default function App() {
|
|||
|
||||
if (activeDefinition?.kind === "spatial") {
|
||||
actions.push(
|
||||
{
|
||||
label: workspaceLayoutProfile.state === "saving"
|
||||
? "Сохраняем компоновку"
|
||||
: "Сохранить компоновку",
|
||||
icon: "save",
|
||||
disabled: workspaceLayoutProfile.state === "saving",
|
||||
onClick: () => void saveWorkspaceLayout(),
|
||||
},
|
||||
{ label: "Настроить визуальный движок", icon: "network", onClick: openSource },
|
||||
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
|
||||
{ label: "Открыть слои", icon: "list", onClick: openLayers },
|
||||
);
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: "Сохранить компоновку",
|
||||
icon: "save",
|
||||
onClick: openLayout,
|
||||
});
|
||||
return actions;
|
||||
}, [activeDefinition?.kind, runtime, sceneSettings, sourceUrl]);
|
||||
}, [activeDefinition?.kind, runtime, saveWorkspaceLayout, workspaceLayoutProfile.state]);
|
||||
|
||||
const header = (
|
||||
<AppHeader
|
||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||
brandLabel="NODEDC MISSION CORE"
|
||||
left={<span className="station-label">MISSION CORE</span>}
|
||||
center={
|
||||
<>
|
||||
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
|
||||
|
|
@ -363,9 +634,29 @@ export default function App() {
|
|||
{phaseLabel(runtime.state?.phase)}
|
||||
</StatusBadge>
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Без источника"}
|
||||
</StatusBadge>
|
||||
<div className="observation-header-tools">
|
||||
<ObservationSessionSelect
|
||||
limit={3}
|
||||
disabled={runtime.pendingAction !== null}
|
||||
onReplayBegin={beginRecordedReplaySwitch}
|
||||
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
|
||||
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
|
||||
/>
|
||||
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
||||
<span
|
||||
className="workspace-layout-feedback"
|
||||
data-error={workspaceLayoutProfile.error ? "true" : undefined}
|
||||
role={workspaceLayoutProfile.error ? "alert" : "status"}
|
||||
>
|
||||
<i
|
||||
className="api-dot"
|
||||
data-status={workspaceLayoutProfile.error ? "error" : "online"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{layoutSaveNotice || workspaceLayoutProfile.error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
)
|
||||
|
|
@ -380,10 +671,16 @@ export default function App() {
|
|||
) : (
|
||||
<WorkspaceRenderer
|
||||
definition={activeDefinition}
|
||||
state={runtime.state}
|
||||
state={activeRuntimeState}
|
||||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
recordedReplay={replayActive ? recordedReplay : null}
|
||||
recordedSessionAdmission={recordedSessionAdmission}
|
||||
sceneSettings={sceneSettings}
|
||||
accumulationSeconds={displayDraft.accumulationSeconds}
|
||||
onAccumulationChange={(accumulationSeconds) =>
|
||||
stageDisplayPatch({ accumulationSeconds })}
|
||||
onAccumulationCommit={flushDisplaySettings}
|
||||
observationLayout={observationLayout}
|
||||
navigation={{
|
||||
openView,
|
||||
|
|
@ -419,6 +716,7 @@ export default function App() {
|
|||
onClick={() => {
|
||||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
setRecordedReplay(null);
|
||||
}}
|
||||
>
|
||||
Сбросить адрес
|
||||
|
|
@ -429,6 +727,7 @@ export default function App() {
|
|||
disabled={!sourceDraft.trim()}
|
||||
onClick={() => {
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
setRecordedReplay(null);
|
||||
}}
|
||||
>
|
||||
Применить адрес
|
||||
|
|
@ -530,21 +829,6 @@ export default function App() {
|
|||
data-scene-active={activeSceneWindow === "display" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("display")}
|
||||
onClose={() => closeSceneWindow("display")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button variant="ghost" onClick={() => setDisplayDraft(defaultSceneSettings)}>
|
||||
Сбросить
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={runtime.pendingAction === "viewer"}
|
||||
onClick={() => void applyDisplaySettings()}
|
||||
>
|
||||
{runtime.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<Inspector
|
||||
defaultOpen={["points"]}
|
||||
|
|
@ -556,41 +840,66 @@ export default function App() {
|
|||
description: "Размер и способ окрашивания",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
label="Размер точки"
|
||||
value={displayDraft.pointSize}
|
||||
min={0.5}
|
||||
max={12}
|
||||
step={0.5}
|
||||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
|
||||
/>
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Атрибут цвета"
|
||||
value={displayDraft.colorMode}
|
||||
options={colorModeOptions}
|
||||
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))}
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<RangeControl
|
||||
label="Размер точки"
|
||||
value={displayDraft.pointSize}
|
||||
min={0.5}
|
||||
max={12}
|
||||
step={0.5}
|
||||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
||||
/>
|
||||
</ControlRow>
|
||||
<ControlRow label="Палитра">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Палитра"
|
||||
value={displayDraft.palette}
|
||||
options={paletteOptions}
|
||||
onChange={(palette) => setDisplayDraft((current) => ({ ...current, palette }))}
|
||||
/>
|
||||
</ControlRow>
|
||||
{displayDraft.palette === "custom" ? (
|
||||
<ControlRow label="Цвет точек">
|
||||
<ColorField
|
||||
label="Цвет точек"
|
||||
value={displayDraft.customColor}
|
||||
onChange={(customColor) => setDisplayDraft((current) => ({ ...current, customColor }))}
|
||||
</div>
|
||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Атрибут цвета"
|
||||
value={displayDraft.colorMode}
|
||||
options={colorModeOptions}
|
||||
disabled={replayActive}
|
||||
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||
<ControlRow label="Палитра">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Палитра"
|
||||
value={displayDraft.palette}
|
||||
options={displayPaletteOptions}
|
||||
onChange={(palette) => stageDisplayPatch({ palette })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
{displayDraft.palette === "custom" ? (
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<ControlRow label="Цвет точек">
|
||||
<ColorField
|
||||
label="Цвет точек"
|
||||
value={displayDraft.customColor}
|
||||
onChange={(customColor) => stageDisplayPatch({ customColor })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
) : null}
|
||||
{replayActive ? (
|
||||
<p className="scene-window-note">
|
||||
В архиве градиентные цвета уже записаны в RRD. Без переэкспорта
|
||||
можно вернуть цвет записи или назначить один свой цвет.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
|
|
@ -601,21 +910,28 @@ export default function App() {
|
|||
description: "История облака и траектория",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
label="Окно накопления"
|
||||
value={displayDraft.accumulationSeconds}
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))}
|
||||
/>
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<RangeControl
|
||||
label="Окно накопления"
|
||||
value={displayDraft.accumulationSeconds}
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
|
||||
/>
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
|
||||
<Checker
|
||||
checked={displayDraft.showTrajectory}
|
||||
label="Показывать траекторию"
|
||||
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
|
||||
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -627,7 +943,7 @@ export default function App() {
|
|||
description: "Сетка, подписи и камеры",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
|
||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
|
||||
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
|
||||
|
|
@ -726,59 +1042,6 @@ export default function App() {
|
|||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={layoutWindowOpen}
|
||||
title="Компоновка рабочей области"
|
||||
subtitle="КОМПОНОВКА / ЧЕРНОВИК"
|
||||
size="sm"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--layout"
|
||||
data-scene-window="layout"
|
||||
data-scene-active={activeSceneWindow === "layout" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("layout")}
|
||||
onClose={() => closeSceneWindow("layout")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button onClick={() => closeSceneWindow("layout")}>Закрыть</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={!layoutName.trim()}
|
||||
onClick={() => setLayoutDraftSaved(true)}
|
||||
>
|
||||
Зафиксировать черновик
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<div className="modal-stack">
|
||||
<TextField
|
||||
label="Название компоновки"
|
||||
value={layoutName}
|
||||
onChange={(event) => {
|
||||
setLayoutName(event.target.value);
|
||||
setLayoutDraftSaved(false);
|
||||
}}
|
||||
placeholder="Название профиля"
|
||||
/>
|
||||
<div className="modal-contract-note" data-tone={layoutDraftSaved ? "success" : "warning"}>
|
||||
<Icon name={layoutDraftSaved ? "check" : "save"} />
|
||||
<div>
|
||||
<strong>{layoutDraftSaved ? "Черновик зафиксирован в текущем сеансе" : "Экспорт RBL ещё не подключён"}</strong>
|
||||
<p>
|
||||
{layoutDraftSaved
|
||||
? "Это состояние интерфейса без записи на диск. Будущий адаптер сохранит профиль Rerun рядом с кодом."
|
||||
: "Окно и контракт сохранения готовы; запись файла не имитируется."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { WorkspaceWindow } from "@nodedc/ui-react";
|
|||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
|
||||
import type { RecordedObservationPlayback } from "./RecordedFmp4Player";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
const WINDOW_WIDTH = 336;
|
||||
const WINDOW_HEIGHT = 210;
|
||||
|
|
@ -11,6 +16,18 @@ const WINDOW_GAP = 16;
|
|||
const WINDOW_INSET = 18;
|
||||
const TIMELINE_CLEARANCE = 64;
|
||||
|
||||
type ClosestTarget = { closest: (selectors: string) => unknown };
|
||||
|
||||
export function shouldCaptureWorkspacePointer(
|
||||
button: number,
|
||||
target: ClosestTarget | null,
|
||||
): boolean {
|
||||
if (button !== 0 || !target) return false;
|
||||
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
||||
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
||||
return !target.closest("button, input, select, textarea, a");
|
||||
}
|
||||
|
||||
export function initialObservationWindowRect(
|
||||
index: number,
|
||||
count = 1,
|
||||
|
|
@ -59,6 +76,11 @@ export function FloatingObservationWindow({
|
|||
onMaximizedChange,
|
||||
onActivate,
|
||||
onClose,
|
||||
playback,
|
||||
prepareRecorded,
|
||||
recordedSessionGate,
|
||||
recordedAdmissionKey,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
index: number;
|
||||
|
|
@ -71,6 +93,14 @@ export function FloatingObservationWindow({
|
|||
onMaximizedChange: (maximized: boolean) => void;
|
||||
onActivate: () => void;
|
||||
onClose: () => void;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepareRecorded?: boolean;
|
||||
recordedSessionGate?: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey?: string | null;
|
||||
onRecordedAdmissionChange?: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
|
|
@ -124,13 +154,29 @@ export function FloatingObservationWindow({
|
|||
active={active}
|
||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||
className="floating-observation-window"
|
||||
onPointerDownCapture={(event) => {
|
||||
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture can fail if the browser ended the pointer between
|
||||
// dispatch and capture. The donor's window listeners remain fallback.
|
||||
}
|
||||
}}
|
||||
closeLabel={`Закрыть ${source.label}`}
|
||||
maximizeLabel={`Развернуть ${source.label}`}
|
||||
restoreLabel={`Восстановить ${source.label}`}
|
||||
moveLabel={`Переместить ${source.label}`}
|
||||
resizeLabel={`Изменить размер ${source.label}`}
|
||||
>
|
||||
<ObservationMedia source={source} />
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
playback={playback}
|
||||
prepareRecorded={prepareRecorded}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
</WorkspaceWindow>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
import { Dropdown, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
type ObservationSessionReplayLaunch,
|
||||
type ObservationSessionSummary,
|
||||
} from "../core/observation/sessionArchive";
|
||||
import { useObservationSessions } from "../core/observation/useObservationSessions";
|
||||
import type {
|
||||
ObservationPreparationPhase,
|
||||
ObservationReplayOutcome,
|
||||
} from "../core/observation/useObservationSessions";
|
||||
import "../styles/observation-sessions.css";
|
||||
|
||||
type SessionVisualState = "ready" | "processing" | "error";
|
||||
|
||||
export function observationSessionVisualState(
|
||||
session: ObservationSessionSummary,
|
||||
{ pending = false, failed = false }: { pending?: boolean; failed?: boolean } = {},
|
||||
): SessionVisualState {
|
||||
if (failed) return "error";
|
||||
if (pending) return "processing";
|
||||
if (session.preparation !== null) {
|
||||
if (["queued", "validating", "exporting", "finalizing"].includes(
|
||||
session.preparation.state,
|
||||
)) return "processing";
|
||||
if (session.preparation.state === "ready" && session.replayable) return "ready";
|
||||
return "error";
|
||||
}
|
||||
return session.status === "recording" ? "processing" : "error";
|
||||
}
|
||||
|
||||
export function observationSessionVisualLabel(state: SessionVisualState): string {
|
||||
if (state === "ready") return "Готово";
|
||||
if (state === "processing") return "Обработка";
|
||||
return "Ошибка";
|
||||
}
|
||||
|
||||
const modalityLabel: Record<string, string> = {
|
||||
"point-cloud": "облако точек",
|
||||
pose: "траектория",
|
||||
trajectory: "траектория",
|
||||
video: "видео",
|
||||
image: "изображения",
|
||||
depth: "глубина",
|
||||
telemetry: "телеметрия",
|
||||
};
|
||||
|
||||
const preparationLabel: Record<ObservationPreparationPhase, string> = {
|
||||
requesting: "Запрашиваем подготовку",
|
||||
queued: "В очереди",
|
||||
validating: "Проверяем запись",
|
||||
exporting: "Готовим облако точек",
|
||||
finalizing: "Завершаем подготовку",
|
||||
failed: "Подготовка не выполнена",
|
||||
cancelled: "Подготовка отменена",
|
||||
};
|
||||
|
||||
function progressCopy(
|
||||
phase: ObservationPreparationPhase,
|
||||
progress: number | null,
|
||||
): string {
|
||||
const label = preparationLabel[phase];
|
||||
return progress === null ? label : `${label} · ${Math.round(progress * 100)}%`;
|
||||
}
|
||||
|
||||
function formatStartedAt(value: string): string {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const totalSeconds = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(totalSeconds / 3_600);
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
return hours > 0
|
||||
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
|
||||
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function sessionDescription(session: ObservationSessionSummary): string {
|
||||
const modalities = session.modalities.length
|
||||
? session.modalities.map((value) => modalityLabel[value] ?? value).join(" · ")
|
||||
: "каналы не зафиксированы";
|
||||
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
|
||||
}
|
||||
|
||||
export function ObservationSessionSelect({
|
||||
limit = 3,
|
||||
disabled = false,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
disabled?: boolean;
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplayAccepted?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplaySettled?: (
|
||||
session: ObservationSessionSummary,
|
||||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
}) {
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
});
|
||||
const triggerCopy = sessions.replayProgress
|
||||
? progressCopy(sessions.replayProgress.phase, sessions.replayProgress.progress)
|
||||
: sessions.state === "loading"
|
||||
? "Загружаем сессии…"
|
||||
: "Сохранённые сессии";
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="observation-session-select"
|
||||
placement="bottom-end"
|
||||
width={390}
|
||||
offset={10}
|
||||
disabled={disabled}
|
||||
surfaceRole="dialog"
|
||||
surfaceClassName="observation-session-menu"
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||
<button
|
||||
ref={(node) => {
|
||||
setAnchorRef(node);
|
||||
setTriggerRef(node);
|
||||
}}
|
||||
type="button"
|
||||
className="observation-session-select__trigger"
|
||||
data-active={open ? "true" : undefined}
|
||||
aria-label="Открыть сохранённые сессии наблюдения"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={15} />
|
||||
<span>{triggerCopy}</span>
|
||||
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="observation-session-menu__content">
|
||||
<header className="observation-session-menu__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">ИСТОРИЯ НАБЛЮДЕНИЯ</span>
|
||||
<strong>Сохранённые сессии</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Обновить каталог сессий"
|
||||
disabled={sessions.state === "loading"}
|
||||
onClick={() => void sessions.refresh()}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{sessions.items.length > 0 ? (
|
||||
<div className="observation-session-menu__list">
|
||||
{sessions.items.map((session) => {
|
||||
const pending = sessions.replayingSessionId === session.id;
|
||||
const failed = sessions.failedSessionId === session.id;
|
||||
const visualState = observationSessionVisualState(session, { pending, failed });
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-session-option"
|
||||
disabled={pending || !session.replayable}
|
||||
onClick={() => {
|
||||
void sessions.replay(session.id).then((accepted) => {
|
||||
if (accepted) close();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{session.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
{sessionDescription(session)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="observation-session-option__state">
|
||||
{observationSessionVisualLabel(visualState)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : sessions.state === "loading" ? (
|
||||
<div className="observation-session-menu__empty" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Читаем каталог</strong>
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-session-menu__empty">
|
||||
<Icon name={sessions.error ? "alert" : "database"} size={18} />
|
||||
<strong>{sessions.error ? "Каталог недоступен" : "Сессий пока нет"}</strong>
|
||||
<span>{sessions.error ?? "Завершённые записи появятся здесь автоматически."}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.error && sessions.items.length > 0 ? (
|
||||
<footer className="observation-session-menu__error" role="alert">
|
||||
<Icon name="alert" size={13} />
|
||||
<span>{sessions.error}</span>
|
||||
{sessions.failedSessionId ? (
|
||||
<button type="button" onClick={() => void sessions.retry()}>
|
||||
Повторить
|
||||
</button>
|
||||
) : null}
|
||||
</footer>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,14 @@ import type {
|
|||
ObservationSourceModality,
|
||||
} from "../core/runtime/contracts";
|
||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "./RecordedFmp4Player";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
|
|
@ -29,7 +37,24 @@ export function observationSourceStatusLabel(source: ObservationSourceDescriptor
|
|||
return availabilityCopy[source.availability];
|
||||
}
|
||||
|
||||
export function ObservationMedia({ source }: { source: ObservationSourceDescriptor }) {
|
||||
export function ObservationMedia({
|
||||
source,
|
||||
playback,
|
||||
prepareRecorded = true,
|
||||
recordedSessionGate = "ready",
|
||||
recordedAdmissionKey = null,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepareRecorded?: boolean;
|
||||
recordedSessionGate?: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey?: string | null;
|
||||
onRecordedAdmissionChange?: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const sourceSelected = source.activation ? source.activation.selected : true;
|
||||
const deliveryActive = Boolean(source.delivery && sourceSelected);
|
||||
|
||||
|
|
@ -41,6 +66,32 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
|
|||
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
|
||||
}
|
||||
|
||||
if (
|
||||
deliveryActive &&
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
source.modality === "video"
|
||||
) {
|
||||
if (!prepareRecorded) {
|
||||
return (
|
||||
<div className="observation-media__empty" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Ожидает подготовки</strong>
|
||||
<span>Канал будет проверен последовательно в рамках атомарной сессии.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
prepare
|
||||
sessionGate={recordedSessionGate}
|
||||
admissionKey={recordedAdmissionKey}
|
||||
onAdmissionChange={(state) => onRecordedAdmissionChange?.(source.id, state)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
|
||||
return (
|
||||
<video
|
||||
|
|
@ -193,7 +244,8 @@ export function ObservationSourcePicker({
|
|||
</div>
|
||||
)}
|
||||
<footer className="observation-source-menu__foot">
|
||||
Каналы приходят из активного device-плагина; сцена не знает модель оборудования.
|
||||
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
||||
модель оборудования.
|
||||
</footer>
|
||||
</Dropdown>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,15 @@ export function ObservationTimeline({
|
|||
mode = "live-only",
|
||||
seekable = false,
|
||||
synchronization = "host-arrival-best-effort",
|
||||
rangeNs = null,
|
||||
currentNs = null,
|
||||
playing = false,
|
||||
onSeek,
|
||||
onPlayingChange,
|
||||
onJumpToEnd,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
className = "",
|
||||
}: {
|
||||
active: boolean;
|
||||
|
|
@ -15,38 +24,151 @@ export function ObservationTimeline({
|
|||
mode?: ObservationTimelineMode;
|
||||
seekable?: boolean;
|
||||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
rangeNs?: { min: number; max: number } | null;
|
||||
currentNs?: number | null;
|
||||
playing?: boolean;
|
||||
onSeek?: (timeNs: number) => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
onJumpToEnd?: () => void;
|
||||
accumulationSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const buffered = seekable && (mode === "buffered" || mode === "recorded");
|
||||
const playbackRange = normalizeTimelineRange(rangeNs);
|
||||
const buffered = Boolean(
|
||||
seekable &&
|
||||
(mode === "buffered" || mode === "recorded") &&
|
||||
playbackRange,
|
||||
);
|
||||
const elapsedSeconds = playbackRange
|
||||
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
||||
: 0;
|
||||
const durationSeconds = playbackRange
|
||||
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
||||
: 0;
|
||||
const synchronizationLabel = {
|
||||
"host-arrival-best-effort": "Синхронизация по приходу",
|
||||
"shared-clock": "Общие часы",
|
||||
"frame-accurate": "Покадровая синхронизация",
|
||||
}[synchronization];
|
||||
const accumulationValue = accumulationSeconds === undefined
|
||||
? null
|
||||
: normalizeAccumulationSeconds(accumulationSeconds);
|
||||
return (
|
||||
<div
|
||||
className={`observation-timeline ${className}`.trim()}
|
||||
data-active={active ? "true" : undefined}
|
||||
data-mode={mode}
|
||||
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
||||
>
|
||||
<Button size="compact" variant="ghost" disabled aria-label="Перейти к началу">
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" disabled>
|
||||
{buffered ? "Воспроизвести" : "Только эфир"}
|
||||
</Button>
|
||||
<div className="observation-timeline__track" data-disabled={!buffered ? "true" : undefined}>
|
||||
<span style={{ width: active ? "100%" : "0%" }} />
|
||||
{accumulationValue !== null ? (
|
||||
<div className="observation-timeline__accumulation">
|
||||
<span>Накопление</span>
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
value={accumulationValue}
|
||||
aria-label="Окно накопления облака точек"
|
||||
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
||||
onChange={(event) =>
|
||||
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
||||
onPointerUp={onAccumulationCommit}
|
||||
onTouchEnd={onAccumulationCommit}
|
||||
onKeyUp={onAccumulationCommit}
|
||||
onBlur={onAccumulationCommit}
|
||||
/>
|
||||
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="observation-timeline__playback">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Перейти к началу"
|
||||
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={!buffered || !onPlayingChange}
|
||||
onClick={() => onPlayingChange?.(!playing)}
|
||||
>
|
||||
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
</Button>
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(durationSeconds, 0.001)}
|
||||
step={0.01}
|
||||
value={Math.min(elapsedSeconds, durationSeconds)}
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Позиция воспроизведения"
|
||||
onChange={(event) => {
|
||||
if (!playbackRange) return;
|
||||
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
||||
}}
|
||||
/>
|
||||
<div className="observation-timeline__meta">
|
||||
<code>
|
||||
{buffered
|
||||
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
||||
: active ? "LIVE" : "—:—:—.———"}
|
||||
</code>
|
||||
<small>
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-timeline__follow"
|
||||
data-active={!buffered && active ? "true" : undefined}
|
||||
disabled={buffered ? !onJumpToEnd : true}
|
||||
onClick={onJumpToEnd}
|
||||
>
|
||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="observation-timeline__meta">
|
||||
<code>{active ? "LIVE" : "—:—:—.———"}</code>
|
||||
<small>
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
<span className="observation-timeline__follow" data-active={active ? "true" : undefined}>
|
||||
ЭФИР
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAccumulationSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(120, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
export function formatAccumulationDuration(value: number): string {
|
||||
const seconds = normalizeAccumulationSeconds(value);
|
||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
||||
}
|
||||
|
||||
export function normalizeTimelineRange(
|
||||
range: { min: number; max: number } | null | undefined,
|
||||
): { min: number; max: number } | null {
|
||||
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
||||
if (range.max <= range.min) return null;
|
||||
return range;
|
||||
}
|
||||
|
||||
export function timelineOffsetSeconds(
|
||||
range: { min: number; max: number },
|
||||
currentNs: number,
|
||||
): number {
|
||||
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
||||
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
||||
}
|
||||
|
||||
export function formatTimelineDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
||||
const wholeMinutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds - wholeMinutes * 60;
|
||||
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,735 @@
|
|||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { bytesToHex } from "@noble/hashes/utils.js";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservationRecordedMediaManifest,
|
||||
ObservationSessionContractError,
|
||||
type ObservationRecordedMediaEpoch,
|
||||
type ObservationRecordedMediaManifest,
|
||||
type ObservationRecordedMediaSource,
|
||||
type ObservationSessionFetch,
|
||||
} from "../core/observation/sessionArchive";
|
||||
import {
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
type RecordedAdmissionPhase,
|
||||
type RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
|
||||
export interface RecordedObservationPlayback {
|
||||
currentSeconds: number;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
export interface RecordedMediaLoadProgress {
|
||||
loadedBytes: number;
|
||||
totalBytes: number;
|
||||
loadedParts: number;
|
||||
totalParts: number;
|
||||
}
|
||||
|
||||
interface VerifiedRecordedMediaEpoch {
|
||||
descriptor: ObservationRecordedMediaEpoch;
|
||||
readonly init: ArrayBuffer;
|
||||
readonly segments: readonly ArrayBuffer[];
|
||||
}
|
||||
|
||||
export interface VerifiedRecordedMediaArchive {
|
||||
manifest: ObservationRecordedMediaManifest;
|
||||
epochs: readonly VerifiedRecordedMediaEpoch[];
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
interface RecordedMediaBinaryDescriptor {
|
||||
url: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
accept: string;
|
||||
}
|
||||
|
||||
export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "error";
|
||||
|
||||
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
||||
let recordedMediaWorkerGeneration = 0;
|
||||
|
||||
export function recordedMediaPresentationState(
|
||||
state: "loading" | "ready" | "error",
|
||||
readyGeneration: string | null,
|
||||
selectedGeneration: string | null,
|
||||
waitingForEpoch: boolean,
|
||||
sessionGate: RecordedAdmissionPhase = "ready",
|
||||
): RecordedMediaPresentationState {
|
||||
if (state === "error" || sessionGate === "error") return "error";
|
||||
if (sessionGate !== "ready") return "loading";
|
||||
if (waitingForEpoch) return "waiting";
|
||||
return state === "ready" &&
|
||||
selectedGeneration !== null &&
|
||||
readyGeneration === selectedGeneration
|
||||
? "ready"
|
||||
: "loading";
|
||||
}
|
||||
|
||||
export function selectRecordedMediaEpoch(
|
||||
epochs: readonly ObservationRecordedMediaEpoch[],
|
||||
currentSeconds: number,
|
||||
): ObservationRecordedMediaEpoch | null {
|
||||
if (!epochs.length || !Number.isFinite(currentSeconds)) return null;
|
||||
let selected: ObservationRecordedMediaEpoch | null = null;
|
||||
for (const epoch of epochs) {
|
||||
if (epoch.timelineStartSeconds > currentSeconds) break;
|
||||
selected = epoch;
|
||||
}
|
||||
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
|
||||
}
|
||||
|
||||
export function recordedMediaSeekableCoverage(
|
||||
durationSeconds: number,
|
||||
seekableEndSeconds: number,
|
||||
declaredDurationSeconds: number,
|
||||
toleranceSeconds = RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
|
||||
seekableStartSeconds = 0,
|
||||
): boolean {
|
||||
return (
|
||||
Number.isFinite(durationSeconds) &&
|
||||
Number.isFinite(seekableEndSeconds) &&
|
||||
Number.isFinite(declaredDurationSeconds) &&
|
||||
Number.isFinite(seekableStartSeconds) &&
|
||||
declaredDurationSeconds > 0 &&
|
||||
toleranceSeconds >= 0 &&
|
||||
durationSeconds + toleranceSeconds >= declaredDurationSeconds &&
|
||||
seekableStartSeconds <= toleranceSeconds &&
|
||||
seekableStartSeconds >= -toleranceSeconds &&
|
||||
seekableEndSeconds + toleranceSeconds >= declaredDurationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
export function recordedMediaLocalTime(
|
||||
epochStartSeconds: number,
|
||||
currentSeconds: number,
|
||||
durationSeconds = Number.POSITIVE_INFINITY,
|
||||
): number {
|
||||
if (!Number.isFinite(epochStartSeconds) || !Number.isFinite(currentSeconds)) return 0;
|
||||
const local = Math.max(0, currentSeconds - epochStartSeconds);
|
||||
return Number.isFinite(durationSeconds)
|
||||
? Math.min(local, Math.max(0, durationSeconds))
|
||||
: local;
|
||||
}
|
||||
|
||||
function sourceContract(source: ObservationSourceDescriptor): ObservationRecordedMediaSource | null {
|
||||
const delivery = source.delivery;
|
||||
if (!delivery || delivery.kind !== "recorded-fmp4-manifest") return null;
|
||||
return {
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
modality: "video",
|
||||
manifestUrl: delivery.url,
|
||||
manifestGenerationSha256: delivery.manifestGenerationSha256,
|
||||
byteLength: delivery.byteLength,
|
||||
mediaType: delivery.mediaType,
|
||||
timelineStartSeconds: delivery.timelineStartSeconds,
|
||||
timelineEndSeconds: delivery.timelineEndSeconds,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
}
|
||||
|
||||
function expectedPayloadEtag(digest: string): string {
|
||||
return `"sha256:${digest}"`;
|
||||
}
|
||||
|
||||
export async function fetchVerifiedRecordedMediaBytes(
|
||||
descriptor: RecordedMediaBinaryDescriptor,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: { signal?: AbortSignal; fetcher?: ObservationSessionFetch } = {},
|
||||
): Promise<ArrayBuffer> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(descriptor.url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: descriptor.accept,
|
||||
"If-Match": expectedPayloadEtag(descriptor.sha256),
|
||||
},
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservationSessionContractError(
|
||||
"Не удалось загрузить канонический фрагмент записанного видео.",
|
||||
);
|
||||
}
|
||||
if (!response.ok || response.status !== 200) {
|
||||
throw new ObservationSessionContractError(
|
||||
`Фрагмент записанного видео вернул HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
if (response.headers.get("ETag") !== expectedPayloadEtag(descriptor.sha256)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Фрагмент записанного видео не соответствует immutable manifest.",
|
||||
);
|
||||
}
|
||||
const contentLength = response.headers.get("Content-Length");
|
||||
if (
|
||||
contentLength === null ||
|
||||
!/^[1-9][0-9]*$/.test(contentLength) ||
|
||||
Number(contentLength) !== descriptor.byteLength
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Длина фрагмента записанного видео не соответствует immutable manifest.",
|
||||
);
|
||||
}
|
||||
const payload = await response.arrayBuffer();
|
||||
if (payload.byteLength !== descriptor.byteLength) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Фрагмент записанного видео был усечён во время передачи.",
|
||||
);
|
||||
}
|
||||
const digest = bytesToHex(sha256(new Uint8Array(payload)));
|
||||
if (digest !== descriptor.sha256) {
|
||||
throw new ObservationSessionContractError(
|
||||
"SHA-256 фрагмента записанного видео не совпадает с immutable manifest.",
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function manifestIdentity(manifest: ObservationRecordedMediaManifest): string {
|
||||
return JSON.stringify(manifest);
|
||||
}
|
||||
|
||||
export async function fetchVerifiedRecordedMediaArchive(
|
||||
source: ObservationRecordedMediaSource,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
onProgress,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
onProgress?: (progress: RecordedMediaLoadProgress) => void;
|
||||
} = {},
|
||||
): Promise<VerifiedRecordedMediaArchive> {
|
||||
const manifest = await fetchObservationRecordedMediaManifest(source, {
|
||||
signal,
|
||||
fetcher,
|
||||
expectedGenerationSha256: source.manifestGenerationSha256,
|
||||
});
|
||||
const totalBytes = manifest.epochs.reduce(
|
||||
(archiveTotal, epoch) => archiveTotal + epoch.initByteLength + epoch.segments.reduce(
|
||||
(epochTotal, segment) => epochTotal + segment.byteLength,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
);
|
||||
const totalParts = manifest.epochs.reduce(
|
||||
(total, epoch) => total + 1 + epoch.segments.length,
|
||||
0,
|
||||
);
|
||||
if (
|
||||
totalBytes < 1 ||
|
||||
totalBytes > MAX_RECORDED_MEDIA_SOURCE_BYTES ||
|
||||
totalBytes !== manifest.byteLength ||
|
||||
totalBytes !== source.byteLength
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Размер записанного медиаканала выходит за безопасный лимит браузера.",
|
||||
);
|
||||
}
|
||||
let loadedBytes = 0;
|
||||
let loadedParts = 0;
|
||||
const publishProgress = () => onProgress?.({
|
||||
loadedBytes,
|
||||
totalBytes,
|
||||
loadedParts,
|
||||
totalParts,
|
||||
});
|
||||
publishProgress();
|
||||
|
||||
const epochs: VerifiedRecordedMediaEpoch[] = [];
|
||||
for (const epoch of manifest.epochs) {
|
||||
const init = await fetchVerifiedRecordedMediaBytes({
|
||||
url: epoch.initUrl,
|
||||
byteLength: epoch.initByteLength,
|
||||
sha256: epoch.initSha256,
|
||||
accept: "video/mp4",
|
||||
}, { signal, fetcher });
|
||||
loadedBytes += init.byteLength;
|
||||
loadedParts += 1;
|
||||
publishProgress();
|
||||
|
||||
const segments: ArrayBuffer[] = [];
|
||||
for (const segment of epoch.segments) {
|
||||
const payload = await fetchVerifiedRecordedMediaBytes({
|
||||
url: segment.url,
|
||||
byteLength: segment.byteLength,
|
||||
sha256: segment.sha256,
|
||||
accept: "video/iso.segment",
|
||||
}, { signal, fetcher });
|
||||
segments.push(payload);
|
||||
loadedBytes += payload.byteLength;
|
||||
loadedParts += 1;
|
||||
publishProgress();
|
||||
}
|
||||
epochs.push({ descriptor: epoch, init, segments });
|
||||
}
|
||||
|
||||
// Bind the complete byte set to the same manifest generation at both ends
|
||||
// of the transfer. A replacement during a long camera download fails closed.
|
||||
const confirmed = await fetchObservationRecordedMediaManifest(source, {
|
||||
signal,
|
||||
fetcher,
|
||||
expectedGenerationSha256: manifest.generationSha256,
|
||||
});
|
||||
if (manifestIdentity(confirmed) !== manifestIdentity(manifest)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Manifest записанного видео изменился во время полной загрузки.",
|
||||
);
|
||||
}
|
||||
if (loadedBytes !== source.byteLength) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Полная загрузка камеры не совпала с launch byte_length.",
|
||||
);
|
||||
}
|
||||
return { manifest, epochs, byteLength: loadedBytes };
|
||||
}
|
||||
|
||||
export function appendRecordedMediaBuffer(
|
||||
sourceBuffer: SourceBuffer,
|
||||
payload: ArrayBuffer,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onUpdateEnd = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
reject(new Error("SourceBuffer rejected archived fMP4 data"));
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const cleanup = () => {
|
||||
sourceBuffer.removeEventListener("updateend", onUpdateEnd);
|
||||
sourceBuffer.removeEventListener("error", onError);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true });
|
||||
sourceBuffer.addEventListener("error", onError, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
sourceBuffer.appendBuffer(payload);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function videoHasSeekableArchive(
|
||||
video: HTMLVideoElement,
|
||||
declaredDurationSeconds: number,
|
||||
): boolean {
|
||||
if (video.readyState < 1 || video.seekable.length < 1) return false;
|
||||
return recordedMediaSeekableCoverage(
|
||||
video.duration,
|
||||
video.seekable.end(video.seekable.length - 1),
|
||||
declaredDurationSeconds,
|
||||
RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
|
||||
video.seekable.start(0),
|
||||
);
|
||||
}
|
||||
|
||||
function waitForSeekableArchive(
|
||||
video: HTMLVideoElement,
|
||||
declaredDurationSeconds: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Archived camera did not become seekable"));
|
||||
}, 20_000);
|
||||
const cleanup = () => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
for (const event of events) video.removeEventListener(event, onProgress);
|
||||
video.removeEventListener("error", onError);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const onProgress = () => {
|
||||
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
reject(new Error("Archived camera decode failed"));
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
for (const event of events) video.addEventListener(event, onProgress);
|
||||
video.addEventListener("error", onError, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function mountVerifiedRecordedEpoch(
|
||||
video: HTMLVideoElement,
|
||||
epoch: VerifiedRecordedMediaEpoch,
|
||||
signal: AbortSignal,
|
||||
): Promise<() => void> {
|
||||
const descriptor = epoch.descriptor;
|
||||
if (
|
||||
descriptor.mediaType === "video/mp4" ||
|
||||
!globalThis.MediaSource ||
|
||||
!MediaSource.isTypeSupported(descriptor.mediaType)
|
||||
) {
|
||||
throw new Error("Archived camera codec is not supported");
|
||||
}
|
||||
const mediaSource = new MediaSource();
|
||||
const objectUrl = URL.createObjectURL(mediaSource);
|
||||
const cleanup = () => {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
video.src = objectUrl;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
cleanupListeners();
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanupListeners();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const cleanupListeners = () => {
|
||||
mediaSource.removeEventListener("sourceopen", onOpen);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
mediaSource.addEventListener("sourceopen", onOpen, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
if (signal.aborted || mediaSource.readyState !== "open") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
const sourceBuffer = mediaSource.addSourceBuffer(descriptor.mediaType);
|
||||
await appendRecordedMediaBuffer(sourceBuffer, epoch.init, signal);
|
||||
for (const segment of epoch.segments) {
|
||||
await appendRecordedMediaBuffer(sourceBuffer, segment, signal);
|
||||
}
|
||||
if (signal.aborted || mediaSource.readyState !== "open" || sourceBuffer.updating) {
|
||||
throw new Error("Archived camera MediaSource closed before full append");
|
||||
}
|
||||
mediaSource.endOfStream();
|
||||
await waitForSeekableArchive(
|
||||
video,
|
||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
||||
signal,
|
||||
);
|
||||
return cleanup;
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function RecordedFmp4Player({
|
||||
source,
|
||||
playback,
|
||||
prepare = true,
|
||||
sessionGate = "ready",
|
||||
admissionKey = null,
|
||||
onAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepare?: boolean;
|
||||
sessionGate?: RecordedAdmissionPhase;
|
||||
admissionKey?: string | null;
|
||||
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
||||
onAdmissionChangeRef.current = onAdmissionChange;
|
||||
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
|
||||
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
|
||||
recordedMediaWorkerGeneration += 1;
|
||||
workerRef.current = { admissionKey, generation: recordedMediaWorkerGeneration };
|
||||
}
|
||||
const workerGeneration = workerRef.current.generation;
|
||||
const reportAdmission = (next: RecordedCameraAdmissionState) => {
|
||||
onAdmissionChangeRef.current?.({
|
||||
...next,
|
||||
admissionKey,
|
||||
workerGeneration,
|
||||
});
|
||||
};
|
||||
const recordedDelivery = source.delivery?.kind === "recorded-fmp4-manifest"
|
||||
? source.delivery
|
||||
: null;
|
||||
const contract = useMemo(
|
||||
() => sourceContract(source),
|
||||
[
|
||||
source.id,
|
||||
source.label,
|
||||
recordedDelivery?.id,
|
||||
recordedDelivery?.url,
|
||||
recordedDelivery?.mediaType,
|
||||
recordedDelivery?.manifestGenerationSha256,
|
||||
recordedDelivery?.byteLength,
|
||||
recordedDelivery?.timelineStartSeconds,
|
||||
recordedDelivery?.timelineEndSeconds,
|
||||
],
|
||||
);
|
||||
const [archive, setArchive] = useState<VerifiedRecordedMediaArchive | null>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<RecordedMediaLoadProgress | null>(null);
|
||||
const [bufferRevision, setBufferRevision] = useState(0);
|
||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||
const epoch = useMemo(
|
||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||
[archive?.manifest.epochs, currentSeconds],
|
||||
);
|
||||
const verifiedEpoch = useMemo(
|
||||
() => epoch
|
||||
? archive?.epochs.find(({ descriptor }) => descriptor.ordinal === epoch.ordinal) ?? null
|
||||
: null,
|
||||
[archive?.epochs, epoch],
|
||||
);
|
||||
const waitingForEpoch = Boolean(archive && !epoch);
|
||||
const selectedGeneration = contract && epoch
|
||||
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
|
||||
: null;
|
||||
const visualState = recordedMediaPresentationState(
|
||||
state,
|
||||
readyGeneration,
|
||||
selectedGeneration,
|
||||
waitingForEpoch,
|
||||
sessionGate,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contract) {
|
||||
setArchive(null);
|
||||
setProgress(null);
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: null,
|
||||
message: "Некорректный descriptor записанной камеры.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!prepare) return;
|
||||
const abort = new AbortController();
|
||||
setArchive(null);
|
||||
setProgress(null);
|
||||
setReadyGeneration(null);
|
||||
setState("loading");
|
||||
reportAdmission({
|
||||
phase: "loading",
|
||||
byteLength: contract.byteLength,
|
||||
message: null,
|
||||
});
|
||||
void fetchVerifiedRecordedMediaArchive(contract, {
|
||||
signal: abort.signal,
|
||||
onProgress: (next) => {
|
||||
if (!abort.signal.aborted) setProgress(next);
|
||||
},
|
||||
})
|
||||
.then((loaded) => {
|
||||
if (abort.signal.aborted) return;
|
||||
setArchive(loaded);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (abort.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) {
|
||||
return;
|
||||
}
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: contract.byteLength,
|
||||
message: "Архив записанной камеры не прошёл проверку.",
|
||||
});
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [admissionKey, contract, prepare]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!archive || !contract || !prepare) return;
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
void (async () => {
|
||||
for (const candidate of archive.epochs) {
|
||||
const probe = document.createElement("video");
|
||||
probe.muted = true;
|
||||
probe.playsInline = true;
|
||||
const cleanup = await mountVerifiedRecordedEpoch(probe, candidate, abort.signal);
|
||||
cleanup();
|
||||
if (disposed || abort.signal.aborted) return;
|
||||
}
|
||||
if (disposed || abort.signal.aborted) return;
|
||||
reportAdmission({
|
||||
phase: "ready",
|
||||
byteLength: archive.byteLength,
|
||||
message: null,
|
||||
});
|
||||
})().catch((error: unknown) => {
|
||||
if (
|
||||
disposed ||
|
||||
abort.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
) return;
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive.byteLength,
|
||||
message: "Не все codec epoch записанной камеры декодируются и доступны для seek.",
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
};
|
||||
}, [admissionKey, archive, contract, prepare]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !verifiedEpoch) return;
|
||||
const epochDescriptor = verifiedEpoch.descriptor;
|
||||
const generation = contract
|
||||
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
|
||||
: null;
|
||||
setReadyGeneration(null);
|
||||
setState("loading");
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
const loadEpoch = async () => {
|
||||
try {
|
||||
cleanup = await mountVerifiedRecordedEpoch(video, verifiedEpoch, abort.signal);
|
||||
if (disposed || abort.signal.aborted) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
return;
|
||||
}
|
||||
setBufferRevision((revision) => revision + 1);
|
||||
setReadyGeneration(generation);
|
||||
setState("ready");
|
||||
} catch (error) {
|
||||
if (
|
||||
disposed ||
|
||||
abort.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive?.byteLength ?? null,
|
||||
message: "Записанная камера не стала seekable.",
|
||||
});
|
||||
}
|
||||
};
|
||||
void loadEpoch();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
cleanup?.();
|
||||
};
|
||||
}, [archive?.byteLength, contract, verifiedEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !epoch || visualState !== "ready") return;
|
||||
const target = recordedMediaLocalTime(
|
||||
epoch.timelineStartSeconds,
|
||||
currentSeconds,
|
||||
video.duration,
|
||||
);
|
||||
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.35) {
|
||||
try {
|
||||
video.currentTime = target;
|
||||
} catch {
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive?.byteLength ?? null,
|
||||
message: "Seek записанной камеры завершился ошибкой.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (playback?.playing) {
|
||||
void video.play().catch(() => undefined);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
|
||||
|
||||
const progressPercent = progress && progress.totalBytes > 0
|
||||
? Math.min(100, Math.floor((progress.loadedBytes / progress.totalBytes) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="recorded-media-player"
|
||||
data-state={visualState}
|
||||
aria-busy={visualState === "loading"}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="observation-media__asset"
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
aria-label={source.label}
|
||||
/>
|
||||
{visualState !== "ready" ? (
|
||||
<div
|
||||
className="recorded-media-player__notice"
|
||||
role={visualState === "error" ? "alert" : "status"}
|
||||
>
|
||||
{visualState === "waiting"
|
||||
? "Камера на этой позиции ещё не записывалась"
|
||||
: visualState === "error"
|
||||
? "Записанное видео недоступно"
|
||||
: archive
|
||||
? "Проверяем полную готовность записанного видео…"
|
||||
: `Загружаем и проверяем записанное видео · ${progressPercent}%`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,84 @@
|
|||
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
||||
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
|
||||
|
||||
export function recordedObservationSources(
|
||||
launch: ObservationSessionReplayLaunch | null,
|
||||
): ObservationSourceDescriptor[] {
|
||||
if (!launch) return [];
|
||||
const spatial: ObservationSourceDescriptor = {
|
||||
id: "recorded.spatial.primary",
|
||||
sourceId: "recorded.spatial.primary",
|
||||
semanticChannelId: "spatial.point-cloud.recorded",
|
||||
label: "Сохранённая пространственная сцена",
|
||||
description: "Облако точек и траектория из записи Rerun",
|
||||
modality: "point-cloud",
|
||||
role: "primary",
|
||||
availability: "available",
|
||||
transport: "recording",
|
||||
endpointLabel: "RRD · session_time",
|
||||
previewUrl: launch.sourceUrl,
|
||||
delivery: null,
|
||||
activation: null,
|
||||
provider: {
|
||||
pluginId: "missioncore.session-archive",
|
||||
pluginVersion: "1",
|
||||
modelId: "recorded-spatial",
|
||||
compatibilityProfileId: null,
|
||||
},
|
||||
binding: {},
|
||||
capabilities: {
|
||||
overlay: false,
|
||||
fullscreen: true,
|
||||
resizable: false,
|
||||
defaultVisible: true,
|
||||
timelineMode: "recorded",
|
||||
seekable: true,
|
||||
sessionRecording: true,
|
||||
clockId: launch.timeline,
|
||||
spatialRegistration: "native",
|
||||
},
|
||||
};
|
||||
const media = launch.mediaSources.map((source, index): ObservationSourceDescriptor => ({
|
||||
id: source.id,
|
||||
sourceId: source.id,
|
||||
semanticChannelId: "camera.video.recorded",
|
||||
label: source.label,
|
||||
description: "Сохранённый видеоканал на общей временной шкале сессии",
|
||||
modality: "video",
|
||||
role: "auxiliary",
|
||||
availability: "available",
|
||||
transport: "recording",
|
||||
endpointLabel: "Сохранённая сессия",
|
||||
previewUrl: null,
|
||||
delivery: {
|
||||
id: `${launch.sessionId}:${source.id}`,
|
||||
kind: "recorded-fmp4-manifest",
|
||||
url: source.manifestUrl,
|
||||
mediaType: source.mediaType,
|
||||
manifestGenerationSha256: source.manifestGenerationSha256,
|
||||
byteLength: source.byteLength,
|
||||
timelineStartSeconds: source.timelineStartSeconds,
|
||||
timelineEndSeconds: source.timelineEndSeconds,
|
||||
},
|
||||
activation: null,
|
||||
provider: {
|
||||
pluginId: "missioncore.session-archive",
|
||||
pluginVersion: "1",
|
||||
modelId: "recorded-media",
|
||||
compatibilityProfileId: null,
|
||||
},
|
||||
binding: {},
|
||||
capabilities: {
|
||||
overlay: true,
|
||||
fullscreen: true,
|
||||
resizable: true,
|
||||
defaultVisible: index < 2,
|
||||
timelineMode: "recorded",
|
||||
seekable: true,
|
||||
sessionRecording: true,
|
||||
clockId: "session_time",
|
||||
spatialRegistration: "unresolved",
|
||||
},
|
||||
}));
|
||||
return [spatial, ...media];
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
// A device-agnostic frontend safety policy. Sixteen channels covers multi-rig
|
||||
// vehicles while the independent byte/concurrency limits keep admission
|
||||
// bounded. OPFS-backed sealed generations are the planned scaling path beyond
|
||||
// this in-memory laboratory policy.
|
||||
export const MAX_RECORDED_CAMERA_SOURCES = 16;
|
||||
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 128 * 1024 * 1024;
|
||||
export const MAX_RECORDED_SESSION_CAMERA_BYTES = 512 * 1024 * 1024;
|
||||
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
|
||||
|
||||
export type RecordedAdmissionPhase = "loading" | "ready" | "error";
|
||||
export type RecordedCameraAdmissionPhase = "pending" | "loading" | "ready" | "error";
|
||||
|
||||
export interface RecordedCameraAdmissionState {
|
||||
phase: RecordedCameraAdmissionPhase;
|
||||
byteLength: number | null;
|
||||
message: string | null;
|
||||
admissionKey?: string | null;
|
||||
workerGeneration?: number;
|
||||
}
|
||||
|
||||
export type RecordedCameraAdmissionMap = Readonly<Record<string, RecordedCameraAdmissionState>>;
|
||||
|
||||
export interface RecordedCameraAdmissionDescriptor {
|
||||
id: string;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export function recordedCameraDescriptorPreflight(
|
||||
sources: readonly RecordedCameraAdmissionDescriptor[],
|
||||
): RecordedAdmissionPhase {
|
||||
if (sources.length > MAX_RECORDED_CAMERA_SOURCES) return "error";
|
||||
if (new Set(sources.map(({ id }) => id)).size !== sources.length) return "error";
|
||||
let totalBytes = 0;
|
||||
for (const source of sources) {
|
||||
if (
|
||||
!source.id ||
|
||||
!Number.isSafeInteger(source.byteLength) ||
|
||||
source.byteLength < 1 ||
|
||||
source.byteLength > MAX_RECORDED_MEDIA_SOURCE_BYTES
|
||||
) return "error";
|
||||
totalBytes += source.byteLength;
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_RECORDED_SESSION_CAMERA_BYTES) {
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
|
||||
export function initialRecordedCameraAdmissions(
|
||||
sourceIds: readonly string[],
|
||||
declaredByteLengths: Readonly<Record<string, number>> = {},
|
||||
): Record<string, RecordedCameraAdmissionState> {
|
||||
return Object.fromEntries(sourceIds.map((sourceId) => [sourceId, {
|
||||
phase: "pending" as const,
|
||||
byteLength: declaredByteLengths[sourceId] ?? null,
|
||||
message: null,
|
||||
}]));
|
||||
}
|
||||
|
||||
export function recordedSessionAdmissionPhase(
|
||||
spatialPhase: RecordedAdmissionPhase,
|
||||
sourceIds: readonly string[],
|
||||
cameras: RecordedCameraAdmissionMap,
|
||||
): RecordedAdmissionPhase {
|
||||
if (sourceIds.length > MAX_RECORDED_CAMERA_SOURCES || spatialPhase === "error") {
|
||||
return "error";
|
||||
}
|
||||
let totalBytes = 0;
|
||||
for (const sourceId of sourceIds) {
|
||||
const camera = cameras[sourceId];
|
||||
if (!camera || camera.phase === "error") return "error";
|
||||
if (camera.phase === "ready" && camera.byteLength === null) return "error";
|
||||
if (camera.byteLength !== null) {
|
||||
if (
|
||||
!Number.isSafeInteger(camera.byteLength) ||
|
||||
camera.byteLength < 1 ||
|
||||
camera.byteLength > MAX_RECORDED_MEDIA_SOURCE_BYTES
|
||||
) return "error";
|
||||
totalBytes += camera.byteLength;
|
||||
}
|
||||
}
|
||||
if (totalBytes > MAX_RECORDED_SESSION_CAMERA_BYTES) return "error";
|
||||
if (spatialPhase !== "ready") return "loading";
|
||||
return sourceIds.every((sourceId) => cameras[sourceId]?.phase === "ready")
|
||||
? "ready"
|
||||
: "loading";
|
||||
}
|
||||
|
||||
export function nextRecordedCameraPreparationIds(
|
||||
sourceIds: readonly string[],
|
||||
cameras: RecordedCameraAdmissionMap,
|
||||
preferredSourceIds: ReadonlySet<string> = new Set(),
|
||||
concurrency = MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS,
|
||||
): string[] {
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1) return [];
|
||||
const pending = sourceIds.filter((sourceId) => {
|
||||
const phase = cameras[sourceId]?.phase;
|
||||
return phase === "pending" || phase === "loading" || phase === undefined;
|
||||
});
|
||||
pending.sort((left, right) => (
|
||||
Number(cameras[right]?.phase === "loading") - Number(cameras[left]?.phase === "loading") ||
|
||||
Number(preferredSourceIds.has(right)) - Number(preferredSourceIds.has(left)) ||
|
||||
sourceIds.indexOf(left) - sourceIds.indexOf(right)
|
||||
));
|
||||
return pending.slice(0, concurrency);
|
||||
}
|
||||
|
||||
export function mergeRecordedCameraAdmission(
|
||||
current: RecordedCameraAdmissionState,
|
||||
next: RecordedCameraAdmissionState,
|
||||
): RecordedCameraAdmissionState {
|
||||
const normalized = next.byteLength === null && current.byteLength !== null
|
||||
? { ...next, byteLength: current.byteLength }
|
||||
: next;
|
||||
const currentWorker = current.workerGeneration ?? 0;
|
||||
const nextWorker = normalized.workerGeneration ?? currentWorker;
|
||||
if (nextWorker < currentWorker) return current;
|
||||
if (current.phase === "error") return current;
|
||||
if (nextWorker > currentWorker) return normalized;
|
||||
if (normalized.phase === "error") return normalized;
|
||||
if (current.phase === "ready") return current;
|
||||
return normalized;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,12 +6,25 @@ import {
|
|||
openObservationSource,
|
||||
shouldRestartObservationSource,
|
||||
} from "./layoutPolicy";
|
||||
import {
|
||||
normalizeObservationWindowRect,
|
||||
projectObservationLayoutSnapshot,
|
||||
validateObservationLayoutSnapshot,
|
||||
validateObservationViewportSize,
|
||||
type ObservationLayoutSnapshot,
|
||||
type ObservationViewportSize,
|
||||
type ObservationWindowRect,
|
||||
} from "./workspaceLayout";
|
||||
|
||||
export interface ObservationWindowRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
export type { ObservationWindowRect } from "./workspaceLayout";
|
||||
|
||||
export type ObservationLayoutPresentationMode = "preserve" | "reset";
|
||||
|
||||
export function observationPresentationSourceAfterLayoutApply(
|
||||
currentSourceId: string | null,
|
||||
mode: ObservationLayoutPresentationMode,
|
||||
): string | null {
|
||||
return mode === "preserve" ? currentSourceId : null;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutController {
|
||||
|
|
@ -20,6 +33,7 @@ export interface ObservationLayoutController {
|
|||
activeFloatingSourceId: string | null;
|
||||
maximizedFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
||||
viewportSize: ObservationViewportSize | null;
|
||||
pendingSourceIds: ReadonlySet<string>;
|
||||
toggleSource: (sourceId: string) => Promise<boolean>;
|
||||
hideSource: (sourceId: string) => Promise<boolean>;
|
||||
|
|
@ -27,6 +41,9 @@ export interface ObservationLayoutController {
|
|||
activateFloatingSource: (sourceId: string) => void;
|
||||
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
||||
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
|
||||
setViewportSize: (size: ObservationViewportSize) => void;
|
||||
snapshot: () => ObservationLayoutSnapshot | null;
|
||||
restore: (snapshot: ObservationLayoutSnapshot) => void;
|
||||
}
|
||||
|
||||
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
||||
|
|
@ -50,6 +67,17 @@ function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): strin
|
|||
.join("|");
|
||||
}
|
||||
|
||||
function cloneSnapshot(snapshot: ObservationLayoutSnapshot): ObservationLayoutSnapshot {
|
||||
return {
|
||||
visibleSourceIds: [...snapshot.visibleSourceIds],
|
||||
activeFloatingSourceId: snapshot.activeFloatingSourceId,
|
||||
windowRects: Object.fromEntries(
|
||||
Object.entries(snapshot.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
|
||||
),
|
||||
viewportSize: { ...snapshot.viewportSize },
|
||||
};
|
||||
}
|
||||
|
||||
export function useObservationLayout(
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
|
||||
|
|
@ -58,45 +86,151 @@ export function useObservationLayout(
|
|||
const visibleIdsRef = useRef<string[]>([]);
|
||||
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
||||
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
|
||||
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
|
||||
const [activeFloatingSourceId, setActiveFloatingSourceIdState] = useState<string | null>(null);
|
||||
const activeFloatingSourceIdRef = useRef<string | null>(null);
|
||||
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
|
||||
const [windowRects, setWindowRects] = useState<Record<string, ObservationWindowRect>>({});
|
||||
const [windowRects, setWindowRectsState] = useState<Record<string, ObservationWindowRect>>({});
|
||||
const windowRectsRef = useRef<Record<string, ObservationWindowRect>>({});
|
||||
const [viewportSize, setViewportSizeState] = useState<ObservationViewportSize | null>(null);
|
||||
const viewportSizeRef = useRef<ObservationViewportSize | null>(null);
|
||||
const desiredSnapshotRef = useRef<ObservationLayoutSnapshot | null>(null);
|
||||
const restoredLayoutAuthorityRef = useRef(false);
|
||||
const initializedCatalog = useRef<string | null>(null);
|
||||
const sourceIdList = sources.map((source) => source.id).sort();
|
||||
const sourceIdsIdentity = sourceIdList.join("\u0000");
|
||||
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
|
||||
const sourceIdsRef = useRef<ReadonlySet<string>>(sourceIds);
|
||||
sourceIdsRef.current = sourceIds;
|
||||
const sourcesRef = useRef<readonly ObservationSourceDescriptor[]>(sources);
|
||||
sourcesRef.current = sources;
|
||||
const identity = catalogIdentity(sources);
|
||||
|
||||
const commitVisibleIds = useCallback((next: string[]) => {
|
||||
visibleIdsRef.current = next;
|
||||
setVisibleIds(next);
|
||||
const commitVisibleIds = useCallback((next: readonly string[]) => {
|
||||
const unique = [...new Set(next)];
|
||||
visibleIdsRef.current = unique;
|
||||
setVisibleIds(unique);
|
||||
}, []);
|
||||
|
||||
const clearPresentation = useCallback((removedIds: readonly string[]) => {
|
||||
const commitActiveFloatingSourceId = useCallback((next: string | null) => {
|
||||
activeFloatingSourceIdRef.current = next;
|
||||
setActiveFloatingSourceIdState(next);
|
||||
}, []);
|
||||
|
||||
const commitWindowRects = useCallback((next: Record<string, ObservationWindowRect>) => {
|
||||
windowRectsRef.current = next;
|
||||
setWindowRectsState(next);
|
||||
}, []);
|
||||
|
||||
const persistLiveLayout = useCallback(() => {
|
||||
const currentViewport = viewportSizeRef.current;
|
||||
if (!currentViewport) return;
|
||||
const currentKnownIds = sourceIdsRef.current;
|
||||
const previous = desiredSnapshotRef.current;
|
||||
const unknownVisibleIds = previous?.visibleSourceIds.filter(
|
||||
(sourceId) => !currentKnownIds.has(sourceId),
|
||||
) ?? [];
|
||||
const visibleSourceIds = [...new Set([...unknownVisibleIds, ...visibleIdsRef.current])];
|
||||
const unknownRects = Object.entries(previous?.windowRects ?? {}).filter(
|
||||
([sourceId]) => !currentKnownIds.has(sourceId),
|
||||
);
|
||||
const knownRects = Object.entries(windowRectsRef.current).map(([sourceId, rect]) => [
|
||||
sourceId,
|
||||
normalizeObservationWindowRect(rect, currentViewport),
|
||||
] as const);
|
||||
const previousActive = previous?.activeFloatingSourceId ?? null;
|
||||
const activeFloatingSourceId = activeFloatingSourceIdRef.current ?? (
|
||||
previousActive && !currentKnownIds.has(previousActive) ? previousActive : null
|
||||
);
|
||||
desiredSnapshotRef.current = validateObservationLayoutSnapshot({
|
||||
visibleSourceIds,
|
||||
activeFloatingSourceId: activeFloatingSourceId && visibleSourceIds.includes(activeFloatingSourceId)
|
||||
? activeFloatingSourceId
|
||||
: null,
|
||||
windowRects: Object.fromEntries([...unknownRects, ...knownRects]),
|
||||
viewportSize: currentViewport,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyDesiredSnapshot = useCallback((
|
||||
snapshot: ObservationLayoutSnapshot,
|
||||
presentationMode: ObservationLayoutPresentationMode,
|
||||
) => {
|
||||
const targetViewport = viewportSizeRef.current ?? snapshot.viewportSize;
|
||||
const projected = projectObservationLayoutSnapshot(
|
||||
snapshot,
|
||||
sourceIdsRef.current,
|
||||
targetViewport,
|
||||
);
|
||||
let visibleSourceIds = [...projected.visibleSourceIds];
|
||||
let activeFloatingSourceId = projected.activeFloatingSourceId;
|
||||
if (visibleSourceIds.length === 0 && sourcesRef.current.length > 0) {
|
||||
for (const source of sourcesRef.current.filter(canOpenByDefault)) {
|
||||
visibleSourceIds = openObservationSource(
|
||||
visibleSourceIds,
|
||||
source.id,
|
||||
sourcesRef.current,
|
||||
).visibleIds;
|
||||
}
|
||||
activeFloatingSourceId = sourcesRef.current.find(
|
||||
(source) => visibleSourceIds.includes(source.id) && source.capabilities.overlay,
|
||||
)?.id ?? null;
|
||||
}
|
||||
commitVisibleIds(visibleSourceIds);
|
||||
commitActiveFloatingSourceId(activeFloatingSourceId);
|
||||
commitWindowRects({ ...projected.windowRects });
|
||||
setFocusedSourceIdState((current) =>
|
||||
observationPresentationSourceAfterLayoutApply(current, presentationMode));
|
||||
setMaximizedFloatingSourceId((current) =>
|
||||
observationPresentationSourceAfterLayoutApply(current, presentationMode));
|
||||
}, [commitActiveFloatingSourceId, commitVisibleIds, commitWindowRects]);
|
||||
|
||||
const clearPresentation = useCallback((removedIds: readonly string[], persist = true) => {
|
||||
if (!removedIds.length) return;
|
||||
const removed = new Set(removedIds);
|
||||
setFocusedSourceIdState((current) => current && removed.has(current) ? null : current);
|
||||
setActiveFloatingSourceId((current) => current && removed.has(current) ? null : current);
|
||||
if (
|
||||
activeFloatingSourceIdRef.current &&
|
||||
removed.has(activeFloatingSourceIdRef.current)
|
||||
) {
|
||||
commitActiveFloatingSourceId(null);
|
||||
}
|
||||
setMaximizedFloatingSourceId((current) => current && removed.has(current) ? null : current);
|
||||
}, []);
|
||||
if (persist) persistLiveLayout();
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
applyDesiredSnapshot(desired, "reset");
|
||||
return;
|
||||
}
|
||||
const currentVisible = visibleIdsRef.current;
|
||||
const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId));
|
||||
if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible);
|
||||
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
|
||||
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||
if (activeFloatingSourceIdRef.current && !sourceIds.has(activeFloatingSourceIdRef.current)) {
|
||||
commitActiveFloatingSourceId(null);
|
||||
}
|
||||
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||
setWindowRects((current) => {
|
||||
const entries = Object.entries(current);
|
||||
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
|
||||
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
|
||||
});
|
||||
}, [commitVisibleIds, sourceIds]);
|
||||
const entries = Object.entries(windowRectsRef.current);
|
||||
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
|
||||
if (nextEntries.length !== entries.length) commitWindowRects(Object.fromEntries(nextEntries));
|
||||
}, [
|
||||
applyDesiredSnapshot,
|
||||
commitActiveFloatingSourceId,
|
||||
commitVisibleIds,
|
||||
commitWindowRects,
|
||||
sourceIds,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!identity) {
|
||||
initializedCatalog.current = null;
|
||||
if (!desiredSnapshotRef.current) initializedCatalog.current = null;
|
||||
return;
|
||||
}
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
initializedCatalog.current = identity;
|
||||
return;
|
||||
}
|
||||
if (initializedCatalog.current === identity) return;
|
||||
|
|
@ -109,8 +243,16 @@ export function useObservationLayout(
|
|||
const firstFloating = sources.find(
|
||||
(source) => canOpenByDefault(source) && source.capabilities.overlay,
|
||||
);
|
||||
setActiveFloatingSourceId(firstFloating?.id ?? null);
|
||||
}, [commitVisibleIds, identity, sources]);
|
||||
commitActiveFloatingSourceId(firstFloating?.id ?? null);
|
||||
persistLiveLayout();
|
||||
}, [
|
||||
applyDesiredSnapshot,
|
||||
commitActiveFloatingSourceId,
|
||||
commitVisibleIds,
|
||||
identity,
|
||||
persistLiveLayout,
|
||||
sources,
|
||||
]);
|
||||
|
||||
const selectedDeliveryIdentity = sources
|
||||
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
|
||||
|
|
@ -124,6 +266,7 @@ export function useObservationLayout(
|
|||
.join("|");
|
||||
|
||||
useEffect(() => {
|
||||
if (restoredLayoutAuthorityRef.current) return;
|
||||
const selected = sources.filter(
|
||||
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
|
||||
);
|
||||
|
|
@ -135,8 +278,9 @@ export function useObservationLayout(
|
|||
change.removedIds.forEach((sourceId) => removed.add(sourceId));
|
||||
}
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation([...removed]);
|
||||
}, [clearPresentation, commitVisibleIds, selectedDeliveryIdentity]);
|
||||
clearPresentation([...removed], false);
|
||||
persistLiveLayout();
|
||||
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
|
||||
|
||||
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
|
||||
const groupId = source.activation?.groupId;
|
||||
|
|
@ -160,11 +304,13 @@ export function useObservationLayout(
|
|||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = closeObservationSource(visibleIdsRef.current, sourceId);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation(change.removedIds);
|
||||
clearPresentation(change.removedIds, false);
|
||||
persistLiveLayout();
|
||||
return true;
|
||||
}, [clearPresentation, commitVisibleIds, markPending, setSourceActive, sources]);
|
||||
}, [clearPresentation, commitVisibleIds, markPending, persistLiveLayout, setSourceActive, sources]);
|
||||
|
||||
const toggleSource = useCallback(async (sourceId: string) => {
|
||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||
|
|
@ -180,30 +326,95 @@ export function useObservationLayout(
|
|||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation(change.removedIds);
|
||||
setActiveFloatingSourceId(sourceId);
|
||||
clearPresentation(change.removedIds, false);
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
return true;
|
||||
}, [clearPresentation, commitVisibleIds, hideSource, markPending, setSourceActive, sources]);
|
||||
}, [
|
||||
clearPresentation,
|
||||
commitActiveFloatingSourceId,
|
||||
commitVisibleIds,
|
||||
hideSource,
|
||||
markPending,
|
||||
persistLiveLayout,
|
||||
setSourceActive,
|
||||
sources,
|
||||
]);
|
||||
|
||||
const setFocusedSourceId = useCallback((sourceId: string | null) => {
|
||||
setFocusedSourceIdState(sourceId);
|
||||
if (sourceId) setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
if (sourceId) {
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
}
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
const activateFloatingSource = useCallback((sourceId: string) => {
|
||||
setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
|
||||
setMaximizedFloatingSourceId(maximized ? sourceId : null);
|
||||
if (maximized) setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
if (maximized) {
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
}
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
|
||||
setWindowRects((current) => ({ ...current, [sourceId]: rect }));
|
||||
}, []);
|
||||
if (
|
||||
!Object.values(rect).every((value) => Number.isFinite(value)) ||
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const currentViewport = viewportSizeRef.current;
|
||||
if (currentViewport) normalizeObservationWindowRect(rect, currentViewport);
|
||||
commitWindowRects({ ...windowRectsRef.current, [sourceId]: { ...rect } });
|
||||
persistLiveLayout();
|
||||
}, [commitWindowRects, persistLiveLayout]);
|
||||
|
||||
const setViewportSize = useCallback((size: ObservationViewportSize) => {
|
||||
const next = validateObservationViewportSize(size);
|
||||
const current = viewportSizeRef.current;
|
||||
if (current && current.width === next.width && current.height === next.height) return;
|
||||
viewportSizeRef.current = next;
|
||||
setViewportSizeState(next);
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
desiredSnapshotRef.current = { ...desired, viewportSize: next };
|
||||
// Entering fullscreen changes the shell geometry, which triggers this
|
||||
// ResizeObserver path. Reproject persistent window rectangles without
|
||||
// clearing the transient fullscreen/focus state that caused the resize.
|
||||
applyDesiredSnapshot(desiredSnapshotRef.current, "preserve");
|
||||
} else if (initializedCatalog.current) {
|
||||
persistLiveLayout();
|
||||
}
|
||||
}, [applyDesiredSnapshot, persistLiveLayout]);
|
||||
|
||||
const snapshot = useCallback((): ObservationLayoutSnapshot | null => {
|
||||
if (!viewportSizeRef.current) return null;
|
||||
persistLiveLayout();
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (!desired) return null;
|
||||
return cloneSnapshot(desired);
|
||||
}, [persistLiveLayout]);
|
||||
|
||||
const restore = useCallback((saved: ObservationLayoutSnapshot) => {
|
||||
const validated = validateObservationLayoutSnapshot(saved);
|
||||
const currentViewport = viewportSizeRef.current;
|
||||
desiredSnapshotRef.current = cloneSnapshot({
|
||||
...validated,
|
||||
viewportSize: currentViewport ?? validated.viewportSize,
|
||||
});
|
||||
restoredLayoutAuthorityRef.current = true;
|
||||
applyDesiredSnapshot(desiredSnapshotRef.current, "reset");
|
||||
}, [applyDesiredSnapshot]);
|
||||
|
||||
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
|
||||
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
|
||||
|
|
@ -214,6 +425,7 @@ export function useObservationLayout(
|
|||
activeFloatingSourceId,
|
||||
maximizedFloatingSourceId,
|
||||
windowRects,
|
||||
viewportSize,
|
||||
pendingSourceIds,
|
||||
toggleSource,
|
||||
hideSource,
|
||||
|
|
@ -221,5 +433,8 @@ export function useObservationLayout(
|
|||
activateFloatingSource,
|
||||
setFloatingMaximized,
|
||||
setWindowRect,
|
||||
setViewportSize,
|
||||
snapshot,
|
||||
restore,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,591 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
decodeObservationSessionPreparation,
|
||||
fetchObservationSessionCatalog,
|
||||
fetchObservationSessionPreparation,
|
||||
replayObservationSession,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionPreparation,
|
||||
type ObservationSessionReplayLaunch,
|
||||
type ObservationSessionSummary,
|
||||
} from "./sessionArchive";
|
||||
|
||||
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
|
||||
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
|
||||
export type ObservationPreparationPhase =
|
||||
| "requesting"
|
||||
| ObservationSessionPreparation["state"];
|
||||
|
||||
export interface ObservationReplayProgress {
|
||||
readonly sessionId: string;
|
||||
readonly phase: ObservationPreparationPhase;
|
||||
readonly progress: number | null;
|
||||
readonly cancellable: boolean;
|
||||
}
|
||||
|
||||
export interface ObservationSessionsController {
|
||||
items: readonly ObservationSessionSummary[];
|
||||
state: ObservationSessionsLoadState;
|
||||
error: string | null;
|
||||
replayingSessionId: string | null;
|
||||
preparation: ObservationSessionPreparation | null;
|
||||
replayProgress: ObservationReplayProgress | null;
|
||||
failedSessionId: string | null;
|
||||
refresh: () => Promise<boolean>;
|
||||
replay: (sessionId: string) => Promise<boolean>;
|
||||
retry: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ObservationReplayAttempt {
|
||||
readonly signal: AbortSignal;
|
||||
isCurrent: () => boolean;
|
||||
finish: () => boolean;
|
||||
}
|
||||
|
||||
export interface ObservationReplayCoordinator {
|
||||
begin: () => ObservationReplayAttempt;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export interface ObservationPreparationPollingOptions {
|
||||
signal: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
onUpdate?: (preparation: ObservationSessionPreparation) => void;
|
||||
requestTimeoutMs?: number;
|
||||
heartbeatStallMs?: number;
|
||||
maximumWaitMs?: number;
|
||||
initialPollIntervalMs?: number;
|
||||
maximumPollIntervalMs?: number;
|
||||
now?: () => number;
|
||||
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
||||
}
|
||||
|
||||
const PREPARATION_STORAGE_KEY = "missioncore.observation-session-preparation/v1";
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_HEARTBEAT_STALL_MS = 45_000;
|
||||
const DEFAULT_MAXIMUM_WAIT_MS = 30 * 60_000;
|
||||
const DEFAULT_INITIAL_POLL_INTERVAL_MS = 750;
|
||||
const DEFAULT_MAXIMUM_POLL_INTERVAL_MS = 3_000;
|
||||
|
||||
export class ObservationPreparationStalledError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservationPreparationStalledError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Latest selection wins, even if an obsolete server job finishes later. */
|
||||
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
|
||||
let sequence = 0;
|
||||
let active: AbortController | null = null;
|
||||
|
||||
return {
|
||||
begin() {
|
||||
active?.abort();
|
||||
const controller = new AbortController();
|
||||
const attemptSequence = ++sequence;
|
||||
active = controller;
|
||||
return {
|
||||
signal: controller.signal,
|
||||
isCurrent: () => (
|
||||
!controller.signal.aborted &&
|
||||
active === controller &&
|
||||
sequence === attemptSequence
|
||||
),
|
||||
finish: () => {
|
||||
if (active !== controller || sequence !== attemptSequence) return false;
|
||||
active = null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
sequence += 1;
|
||||
active?.abort();
|
||||
active = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Операция с сохранёнными сессиями завершилась ошибкой.";
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
type PendingPreparationState = Exclude<
|
||||
ObservationSessionPreparation["state"],
|
||||
"failed" | "cancelled"
|
||||
>;
|
||||
|
||||
function pendingPreparation(
|
||||
preparation: ObservationSessionPreparation,
|
||||
): preparation is ObservationSessionPreparation & { state: PendingPreparationState } {
|
||||
return preparation.state !== "failed" && preparation.state !== "cancelled";
|
||||
}
|
||||
|
||||
const PREPARATION_PHASE_ORDER: Record<
|
||||
PendingPreparationState,
|
||||
number
|
||||
> = {
|
||||
queued: 0,
|
||||
validating: 1,
|
||||
exporting: 2,
|
||||
finalizing: 3,
|
||||
};
|
||||
|
||||
function terminalPreparationError(preparation: ObservationSessionPreparation): Error {
|
||||
if (preparation.error) return new Error(preparation.error);
|
||||
return new Error(
|
||||
preparation.state === "cancelled"
|
||||
? "Подготовка записи отменена."
|
||||
: "Сервер не смог подготовить сохранённую сессию.",
|
||||
);
|
||||
}
|
||||
|
||||
function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
};
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function withRequestTimeout<T>(
|
||||
signal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (signal.aborted) throw new DOMException("cancelled", "AbortError");
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const forwardAbort = () => controller.abort();
|
||||
signal.addEventListener("abort", forwardAbort, { once: true });
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
return await operation(controller.signal);
|
||||
} catch (error) {
|
||||
if (timedOut && isAbortError(error)) {
|
||||
throw new ObservationPreparationStalledError(
|
||||
"Сервер слишком долго не отвечает. Подготовку можно повторить.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timer);
|
||||
signal.removeEventListener("abort", forwardAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForObservationReplayPreparation(
|
||||
initial: ObservationSessionPreparation,
|
||||
options: ObservationPreparationPollingOptions,
|
||||
): Promise<ObservationSessionReplayLaunch> {
|
||||
if (!pendingPreparation(initial)) throw terminalPreparationError(initial);
|
||||
const requestTimeoutMs = Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
const heartbeatStallMs = Math.max(250, options.heartbeatStallMs ?? DEFAULT_HEARTBEAT_STALL_MS);
|
||||
const maximumWaitMs = Math.max(heartbeatStallMs, options.maximumWaitMs ?? DEFAULT_MAXIMUM_WAIT_MS);
|
||||
const initialInterval = Math.max(
|
||||
50,
|
||||
options.initialPollIntervalMs ?? DEFAULT_INITIAL_POLL_INTERVAL_MS,
|
||||
);
|
||||
const maximumInterval = Math.max(
|
||||
initialInterval,
|
||||
options.maximumPollIntervalMs ?? DEFAULT_MAXIMUM_POLL_INTERVAL_MS,
|
||||
);
|
||||
const now = options.now ?? Date.now;
|
||||
const sleep = options.sleep ?? defaultSleep;
|
||||
const startedAt = now();
|
||||
let lastHeartbeatAt = startedAt;
|
||||
let lastUpdatedAt = Date.parse(initial.updatedAtUtc);
|
||||
let current: ObservationSessionPreparation = initial;
|
||||
let interval = initialInterval;
|
||||
options.onUpdate?.(current);
|
||||
|
||||
while (true) {
|
||||
await sleep(interval, options.signal);
|
||||
const response = await withRequestTimeout(
|
||||
options.signal,
|
||||
requestTimeoutMs,
|
||||
(signal) => fetchObservationSessionPreparation(current, {
|
||||
signal,
|
||||
fetcher: options.fetcher,
|
||||
}),
|
||||
);
|
||||
if (response.kind === "ready") return response.launch;
|
||||
|
||||
const next = response.preparation;
|
||||
const nextUpdatedAt = Date.parse(next.updatedAtUtc);
|
||||
if (nextUpdatedAt < lastUpdatedAt) {
|
||||
throw new Error("Сервер вернул устаревшее состояние подготовки записи.");
|
||||
}
|
||||
if (nextUpdatedAt > lastUpdatedAt) {
|
||||
lastUpdatedAt = nextUpdatedAt;
|
||||
lastHeartbeatAt = now();
|
||||
} else if (
|
||||
next.state !== current.state ||
|
||||
next.progress !== current.progress ||
|
||||
next.cancellable !== current.cancellable
|
||||
) {
|
||||
throw new Error("Сервер изменил подготовку без обновления heartbeat timestamp.");
|
||||
}
|
||||
if (pendingPreparation(next) && pendingPreparation(current)) {
|
||||
if (PREPARATION_PHASE_ORDER[next.state] < PREPARATION_PHASE_ORDER[current.state]) {
|
||||
throw new Error("Сервер вернул подготовку на уже завершённую фазу.");
|
||||
}
|
||||
if (
|
||||
next.progress !== null &&
|
||||
current.progress !== null &&
|
||||
next.progress + Number.EPSILON < current.progress
|
||||
) {
|
||||
throw new Error("Прогресс подготовки не может уменьшаться.");
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
options.onUpdate?.(current);
|
||||
if (!pendingPreparation(current)) throw terminalPreparationError(current);
|
||||
if (current.state !== "queued" && now() - lastHeartbeatAt > heartbeatStallMs) {
|
||||
throw new ObservationPreparationStalledError(
|
||||
"Подготовка перестала обновляться. Текущая сцена сохранена; повторите запуск.",
|
||||
);
|
||||
}
|
||||
if (now() - startedAt > maximumWaitMs) {
|
||||
throw new ObservationPreparationStalledError(
|
||||
"Подготовка превысила допустимое время. Текущая сцена сохранена; повторите запуск.",
|
||||
);
|
||||
}
|
||||
interval = Math.min(maximumInterval, Math.round(interval * 1.35));
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveObservationSessionReplay(
|
||||
sessionId: string,
|
||||
options: ObservationPreparationPollingOptions,
|
||||
): Promise<ObservationSessionReplayLaunch> {
|
||||
const response = await withRequestTimeout(
|
||||
options.signal,
|
||||
Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS),
|
||||
(signal) => replayObservationSession(sessionId, { signal, fetcher: options.fetcher }),
|
||||
);
|
||||
if (response.kind === "ready") return response.launch;
|
||||
if (!pendingPreparation(response.preparation)) {
|
||||
options.onUpdate?.(response.preparation);
|
||||
throw terminalPreparationError(response.preparation);
|
||||
}
|
||||
return waitForObservationReplayPreparation(response.preparation, options);
|
||||
}
|
||||
|
||||
function preparationStoragePayload(preparation: ObservationSessionPreparation): unknown {
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-preparation/v1",
|
||||
preparation: {
|
||||
preparation_id: preparation.preparationId,
|
||||
session_id: preparation.sessionId,
|
||||
state: preparation.state,
|
||||
progress: preparation.progress,
|
||||
updated_at_utc: preparation.updatedAtUtc,
|
||||
status_url: preparation.statusUrl,
|
||||
cancellable: preparation.cancellable,
|
||||
...(preparation.state === "failed" || preparation.state === "cancelled"
|
||||
? { retryable: preparation.retryable, error: preparation.error }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function storeObservationReplayPreparation(
|
||||
preparation: ObservationSessionPreparation,
|
||||
storage: Storage = window.localStorage,
|
||||
): void {
|
||||
if (!pendingPreparation(preparation)) {
|
||||
storage.removeItem(PREPARATION_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
storage.setItem(PREPARATION_STORAGE_KEY, JSON.stringify(preparationStoragePayload(preparation)));
|
||||
}
|
||||
|
||||
export function loadObservationReplayPreparation(
|
||||
storage: Storage = window.localStorage,
|
||||
): ObservationSessionPreparation | null {
|
||||
const serialized = storage.getItem(PREPARATION_STORAGE_KEY);
|
||||
if (!serialized) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(serialized) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("preparation" in parsed) ||
|
||||
typeof parsed.preparation !== "object" ||
|
||||
parsed.preparation === null ||
|
||||
!("session_id" in parsed.preparation) ||
|
||||
typeof parsed.preparation.session_id !== "string"
|
||||
) {
|
||||
throw new Error("invalid persisted preparation");
|
||||
}
|
||||
const preparation = decodeObservationSessionPreparation(
|
||||
parsed,
|
||||
parsed.preparation.session_id,
|
||||
);
|
||||
return pendingPreparation(preparation) ? preparation : null;
|
||||
} catch {
|
||||
storage.removeItem(PREPARATION_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearObservationReplayPreparation(
|
||||
storage: Storage = window.localStorage,
|
||||
): void {
|
||||
storage.removeItem(PREPARATION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function useObservationSessions({
|
||||
limit = 3,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
/** Called only after the archive is ready, immediately before replacing the old viewer. */
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplayAccepted?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplaySettled?: (
|
||||
session: ObservationSessionSummary,
|
||||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
} = {}): ObservationSessionsController {
|
||||
const [items, setItems] = useState<ObservationSessionSummary[]>([]);
|
||||
const [state, setState] = useState<ObservationSessionsLoadState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replayingSessionId, setReplayingSessionId] = useState<string | null>(null);
|
||||
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
|
||||
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
|
||||
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const catalogSequence = useRef(0);
|
||||
const reattachStarted = useRef(false);
|
||||
const preparationPollSequence = useRef(0);
|
||||
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
|
||||
if (replayCoordinator.current === null) {
|
||||
replayCoordinator.current = createObservationReplayCoordinator();
|
||||
}
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const sequence = ++catalogSequence.current;
|
||||
setState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
setState("ready");
|
||||
return true;
|
||||
} catch (loadError) {
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setState("error");
|
||||
setError(errorMessage(loadError));
|
||||
return false;
|
||||
}
|
||||
}, [safeLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
catalogSequence.current += 1;
|
||||
replayCoordinator.current?.cancel();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const catalogHasActivePreparation = items.some((item) => (
|
||||
item.preparation !== null &&
|
||||
["queued", "validating", "exporting", "finalizing"].includes(item.preparation.state)
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || !catalogHasActivePreparation) return;
|
||||
const sequence = ++preparationPollSequence.current;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({
|
||||
limit: safeLimit,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (
|
||||
mounted.current &&
|
||||
!controller.signal.aborted &&
|
||||
sequence === preparationPollSequence.current
|
||||
) {
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
}
|
||||
} catch (pollError) {
|
||||
if (
|
||||
mounted.current &&
|
||||
!controller.signal.aborted &&
|
||||
sequence === preparationPollSequence.current
|
||||
) {
|
||||
setError(errorMessage(pollError));
|
||||
}
|
||||
}
|
||||
}, 1_500);
|
||||
return () => {
|
||||
preparationPollSequence.current += 1;
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [catalogHasActivePreparation, items, safeLimit, state]);
|
||||
|
||||
const executeReplay = useCallback(async (
|
||||
session: ObservationSessionSummary,
|
||||
resumedPreparation?: ObservationSessionPreparation,
|
||||
) => {
|
||||
const attempt = replayCoordinator.current!.begin();
|
||||
setReplayingSessionId(session.id);
|
||||
setFailedSessionId(null);
|
||||
setError(null);
|
||||
setReplayProgress({
|
||||
sessionId: session.id,
|
||||
phase: resumedPreparation?.state ?? "requesting",
|
||||
progress: resumedPreparation?.progress ?? null,
|
||||
cancellable: resumedPreparation?.cancellable ?? false,
|
||||
});
|
||||
let outcome: ObservationReplayOutcome = "cancelled";
|
||||
try {
|
||||
const onUpdate = (next: ObservationSessionPreparation) => {
|
||||
if (!mounted.current || !attempt.isCurrent()) return;
|
||||
setPreparation(next);
|
||||
setReplayProgress({
|
||||
sessionId: next.sessionId,
|
||||
phase: next.state,
|
||||
progress: next.progress,
|
||||
cancellable: next.cancellable,
|
||||
});
|
||||
try {
|
||||
storeObservationReplayPreparation(next);
|
||||
} catch {
|
||||
// Private browsing/storage quota must not break replay preparation.
|
||||
}
|
||||
};
|
||||
const launch = resumedPreparation
|
||||
? await waitForObservationReplayPreparation(resumedPreparation, {
|
||||
signal: attempt.signal,
|
||||
onUpdate,
|
||||
})
|
||||
: await resolveObservationSessionReplay(session.id, {
|
||||
signal: attempt.signal,
|
||||
onUpdate,
|
||||
});
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
|
||||
// The current scene stays mounted throughout preparation. Only now that
|
||||
// the launch descriptor exists do we release the previous viewer.
|
||||
await onReplayBegin?.(session, launch);
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
await onReplayAccepted?.(session, launch);
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
outcome = "accepted";
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
} catch {
|
||||
// Storage is optional; an accepted replay must not turn into an error.
|
||||
}
|
||||
setPreparation(null);
|
||||
setReplayProgress(null);
|
||||
return true;
|
||||
} catch (replayError) {
|
||||
const aborted = isAbortError(replayError);
|
||||
outcome = aborted ? "cancelled" : "error";
|
||||
if (mounted.current && attempt.isCurrent() && !aborted) {
|
||||
setError(errorMessage(replayError));
|
||||
setFailedSessionId(session.id);
|
||||
setReplayProgress(null);
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
} catch {
|
||||
// Storage is optional for the current browser lifetime.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
const current = attempt.finish();
|
||||
if (mounted.current && current) {
|
||||
setReplayingSessionId(null);
|
||||
await onReplaySettled?.(session, outcome);
|
||||
}
|
||||
}
|
||||
}, [onReplayAccepted, onReplayBegin, onReplaySettled]);
|
||||
|
||||
const replay = useCallback(async (sessionId: string) => {
|
||||
const session = items.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !session.replayable) return false;
|
||||
reattachStarted.current = true;
|
||||
return executeReplay(session);
|
||||
}, [executeReplay, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || reattachStarted.current) return;
|
||||
reattachStarted.current = true;
|
||||
let stored: ObservationSessionPreparation | null = null;
|
||||
try {
|
||||
stored = loadObservationReplayPreparation();
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
if (!stored) return;
|
||||
const session = items.find((candidate) => candidate.id === stored?.sessionId);
|
||||
if (!session?.replayable) {
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
} catch {
|
||||
// Storage may be unavailable in hardened browser profiles.
|
||||
}
|
||||
return;
|
||||
}
|
||||
void executeReplay(session, stored);
|
||||
}, [executeReplay, items, state]);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
if (!failedSessionId) return false;
|
||||
return replay(failedSessionId);
|
||||
}, [failedSessionId, replay]);
|
||||
|
||||
return {
|
||||
items,
|
||||
state,
|
||||
error,
|
||||
replayingSessionId,
|
||||
preparation,
|
||||
replayProgress,
|
||||
failedSessionId,
|
||||
refresh,
|
||||
replay,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
|
||||
import {
|
||||
initialRecordedCameraAdmissions,
|
||||
mergeRecordedCameraAdmission,
|
||||
nextRecordedCameraPreparationIds,
|
||||
recordedCameraDescriptorPreflight,
|
||||
recordedSessionAdmissionPhase,
|
||||
type RecordedAdmissionPhase,
|
||||
type RecordedCameraAdmissionMap,
|
||||
type RecordedCameraAdmissionState,
|
||||
} from "./recordedSessionAdmission";
|
||||
|
||||
interface AdmissionSnapshot {
|
||||
key: string | null;
|
||||
spatialPhase: RecordedAdmissionPhase;
|
||||
cameras: Record<string, RecordedCameraAdmissionState>;
|
||||
}
|
||||
|
||||
export interface RecordedSessionAdmissionController {
|
||||
key: string;
|
||||
phase: RecordedAdmissionPhase;
|
||||
cameraSourceIds: readonly string[];
|
||||
cameras: RecordedCameraAdmissionMap;
|
||||
activeCameraSourceIds: ReadonlySet<string>;
|
||||
reportSpatial: (key: string, phase: RecordedAdmissionPhase) => void;
|
||||
reportCamera: (
|
||||
key: string,
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function replayAdmissionKey(replay: ObservationSessionReplayLaunch): string {
|
||||
return [
|
||||
replay.sessionId,
|
||||
replay.sha256,
|
||||
...replay.mediaSources.map((source) => (
|
||||
[
|
||||
source.id,
|
||||
source.manifestGenerationSha256,
|
||||
source.byteLength,
|
||||
source.timelineStartSeconds,
|
||||
source.timelineEndSeconds,
|
||||
].join(":")
|
||||
)),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function useRecordedSessionAdmission(
|
||||
replay: ObservationSessionReplayLaunch | null,
|
||||
): RecordedSessionAdmissionController | null {
|
||||
const key = replay ? replayAdmissionKey(replay) : null;
|
||||
const cameraSourceIds = useMemo(
|
||||
() => replay?.mediaSources.map(({ id }) => id) ?? [],
|
||||
[replay],
|
||||
);
|
||||
const declaredByteLengths = useMemo(
|
||||
() => Object.fromEntries(
|
||||
(replay?.mediaSources ?? []).map(({ id, byteLength }) => [id, byteLength]),
|
||||
),
|
||||
[replay],
|
||||
);
|
||||
const [snapshot, setSnapshot] = useState<AdmissionSnapshot>(() => ({
|
||||
key,
|
||||
spatialPhase: "loading",
|
||||
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshot({
|
||||
key,
|
||||
spatialPhase: "loading",
|
||||
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
|
||||
});
|
||||
}, [cameraSourceIds, declaredByteLengths, key]);
|
||||
|
||||
const reportSpatial = useCallback((reportedKey: string, phase: RecordedAdmissionPhase) => {
|
||||
setSnapshot((current) => current.key !== reportedKey
|
||||
? current
|
||||
: { ...current, spatialPhase: phase });
|
||||
}, []);
|
||||
|
||||
const reportCamera = useCallback((
|
||||
reportedKey: string,
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
setSnapshot((current) => {
|
||||
if (current.key !== reportedKey || !(sourceId in current.cameras)) return current;
|
||||
const merged = mergeRecordedCameraAdmission(current.cameras[sourceId], state);
|
||||
if (merged === current.cameras[sourceId]) return current;
|
||||
return { ...current, cameras: { ...current.cameras, [sourceId]: merged } };
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!replay || !key) return null;
|
||||
const current = snapshot.key === key
|
||||
? snapshot
|
||||
: {
|
||||
key,
|
||||
spatialPhase: "loading" as const,
|
||||
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
|
||||
};
|
||||
const descriptorPhase = recordedCameraDescriptorPreflight(replay.mediaSources);
|
||||
const phase = descriptorPhase === "error"
|
||||
? "error"
|
||||
: recordedSessionAdmissionPhase(
|
||||
current.spatialPhase,
|
||||
cameraSourceIds,
|
||||
current.cameras,
|
||||
);
|
||||
const activeCameraSourceIds = new Set(phase === "error"
|
||||
? []
|
||||
: nextRecordedCameraPreparationIds(cameraSourceIds, current.cameras));
|
||||
return {
|
||||
key,
|
||||
phase,
|
||||
cameraSourceIds,
|
||||
cameras: current.cameras,
|
||||
activeCameraSourceIds,
|
||||
reportSpatial,
|
||||
reportCamera,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservationWorkspaceLayoutProfile,
|
||||
saveObservationWorkspaceLayoutProfile,
|
||||
WorkspaceLayoutApiError,
|
||||
type ObservationWorkspaceLayoutProfile,
|
||||
} from "./workspaceLayout";
|
||||
|
||||
export type WorkspaceLayoutProfileState =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "saving"
|
||||
| "error"
|
||||
| "conflict";
|
||||
|
||||
export interface WorkspaceLayoutProfileController {
|
||||
profile: ObservationWorkspaceLayoutProfile | null;
|
||||
state: WorkspaceLayoutProfileState;
|
||||
error: string | null;
|
||||
refresh: () => Promise<ObservationWorkspaceLayoutProfile | null>;
|
||||
save: (
|
||||
profile: ObservationWorkspaceLayoutProfile,
|
||||
) => Promise<ObservationWorkspaceLayoutProfile | null>;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Операция с профилем рабочей поверхности завершилась ошибкой.";
|
||||
}
|
||||
|
||||
export function useWorkspaceLayoutProfile(): WorkspaceLayoutProfileController {
|
||||
const [profile, setProfile] = useState<ObservationWorkspaceLayoutProfile | null>(null);
|
||||
const profileRef = useRef<ObservationWorkspaceLayoutProfile | null>(null);
|
||||
const [state, setState] = useState<WorkspaceLayoutProfileState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const requestSequence = useRef(0);
|
||||
const saveInFlight = useRef(false);
|
||||
|
||||
const commitProfile = useCallback((next: ObservationWorkspaceLayoutProfile | null) => {
|
||||
profileRef.current = next;
|
||||
setProfile(next);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const sequence = ++requestSequence.current;
|
||||
setState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const loaded = await fetchObservationWorkspaceLayoutProfile();
|
||||
if (!mounted.current || sequence !== requestSequence.current) return null;
|
||||
commitProfile(loaded);
|
||||
setState("ready");
|
||||
return loaded;
|
||||
} catch (loadError) {
|
||||
if (!mounted.current || sequence !== requestSequence.current) return null;
|
||||
setState("error");
|
||||
setError(errorMessage(loadError));
|
||||
return null;
|
||||
}
|
||||
}, [commitProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
requestSequence.current += 1;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "error") return;
|
||||
// The desktop shell can become ready before its loopback API. Keep the
|
||||
// last-layout restore self-healing instead of requiring a page reload once
|
||||
// the local service finishes starting.
|
||||
const timer = window.setTimeout(() => void refresh(), 5_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [refresh, state]);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshAfterNetworkRecovery = () => void refresh();
|
||||
window.addEventListener("online", refreshAfterNetworkRecovery);
|
||||
return () => window.removeEventListener("online", refreshAfterNetworkRecovery);
|
||||
}, [refresh]);
|
||||
|
||||
const save = useCallback(async (draft: ObservationWorkspaceLayoutProfile) => {
|
||||
if (saveInFlight.current) return null;
|
||||
const current = profileRef.current;
|
||||
if (current && draft.revision !== current.revision) {
|
||||
setState("conflict");
|
||||
setError("Профиль изменился после открытия. Обновите данные перед сохранением.");
|
||||
return null;
|
||||
}
|
||||
saveInFlight.current = true;
|
||||
setState("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await saveObservationWorkspaceLayoutProfile(draft);
|
||||
if (!mounted.current) return null;
|
||||
commitProfile(saved);
|
||||
setState("ready");
|
||||
return saved;
|
||||
} catch (saveError) {
|
||||
if (!mounted.current) return null;
|
||||
const conflict = saveError instanceof WorkspaceLayoutApiError && saveError.conflict;
|
||||
setState(conflict ? "conflict" : "error");
|
||||
setError(errorMessage(saveError));
|
||||
return null;
|
||||
} finally {
|
||||
saveInFlight.current = false;
|
||||
}
|
||||
}, [commitProfile]);
|
||||
|
||||
return { profile, state, error, refresh, save };
|
||||
}
|
||||
|
|
@ -0,0 +1,599 @@
|
|||
import type { SceneSettings } from "../../sceneSettings";
|
||||
|
||||
export const OBSERVATION_WORKSPACE_ID = "observation.spatial" as const;
|
||||
export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 1 as const;
|
||||
export const OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT =
|
||||
"/api/v1/workspace-layouts/observation.spatial" as const;
|
||||
|
||||
export type ObservationToolWindowId = "sources" | "display" | "layers";
|
||||
|
||||
export interface ObservationToolWindows {
|
||||
sourcesOpen: boolean;
|
||||
displayOpen: boolean;
|
||||
layersOpen: boolean;
|
||||
order: readonly ObservationToolWindowId[];
|
||||
}
|
||||
|
||||
export interface ObservationWindowRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ObservationViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface NormalizedObservationWindowRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutSnapshot {
|
||||
visibleSourceIds: readonly string[];
|
||||
activeFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, NormalizedObservationWindowRect>>;
|
||||
viewportSize: ObservationViewportSize;
|
||||
}
|
||||
|
||||
export interface ProjectedObservationLayout {
|
||||
visibleSourceIds: readonly string[];
|
||||
activeFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
||||
}
|
||||
|
||||
export interface ObservationWorkspaceLayoutProfile extends ObservationLayoutSnapshot {
|
||||
version: typeof OBSERVATION_WORKSPACE_LAYOUT_VERSION;
|
||||
revision: number;
|
||||
workspaceId: typeof OBSERVATION_WORKSPACE_ID;
|
||||
sceneSettings: SceneSettings;
|
||||
toolWindows: ObservationToolWindows;
|
||||
}
|
||||
|
||||
export type WorkspaceLayoutFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
type WireProfile = {
|
||||
version: number;
|
||||
revision: number;
|
||||
workspace_id: string;
|
||||
scene_settings: {
|
||||
projection: string;
|
||||
point_size: number;
|
||||
color_mode: string;
|
||||
palette: string;
|
||||
custom_color: string;
|
||||
accumulation_seconds: number;
|
||||
show_points: boolean;
|
||||
show_trajectory: boolean;
|
||||
show_grid: boolean;
|
||||
show_labels: boolean;
|
||||
show_camera_frustums: boolean;
|
||||
};
|
||||
tool_windows: {
|
||||
sources_open: boolean;
|
||||
display_open: boolean;
|
||||
layers_open: boolean;
|
||||
order: ObservationToolWindowId[];
|
||||
};
|
||||
visible_source_ids: string[];
|
||||
active_floating_source_id: string | null;
|
||||
window_rects: Record<string, NormalizedObservationWindowRect>;
|
||||
viewport_size: ObservationViewportSize;
|
||||
};
|
||||
|
||||
const PROFILE_KEYS = new Set([
|
||||
"version",
|
||||
"revision",
|
||||
"workspace_id",
|
||||
"scene_settings",
|
||||
"tool_windows",
|
||||
"visible_source_ids",
|
||||
"active_floating_source_id",
|
||||
"window_rects",
|
||||
"viewport_size",
|
||||
]);
|
||||
const SCENE_KEYS = new Set([
|
||||
"projection",
|
||||
"point_size",
|
||||
"color_mode",
|
||||
"palette",
|
||||
"custom_color",
|
||||
"accumulation_seconds",
|
||||
"show_points",
|
||||
"show_trajectory",
|
||||
"show_grid",
|
||||
"show_labels",
|
||||
"show_camera_frustums",
|
||||
]);
|
||||
const TOOL_WINDOW_KEYS = new Set(["sources_open", "display_open", "layers_open", "order"]);
|
||||
const VIEWPORT_KEYS = new Set(["width", "height"]);
|
||||
const RECT_KEYS = new Set(["x", "y", "width", "height"]);
|
||||
const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]);
|
||||
const COLOR_MODES = new Set<SceneSettings["colorMode"]>([
|
||||
"intensity",
|
||||
"height",
|
||||
"distance",
|
||||
"rgb",
|
||||
"class",
|
||||
]);
|
||||
const PALETTES = new Set<SceneSettings["palette"]>([
|
||||
"turbo",
|
||||
"viridis",
|
||||
"plasma",
|
||||
"grayscale",
|
||||
"custom",
|
||||
]);
|
||||
const TOOL_WINDOW_IDS = new Set<ObservationToolWindowId>(["sources", "display", "layers"]);
|
||||
const SAFE_STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const MAX_SOURCES = 256;
|
||||
const MAX_VIEWPORT_EDGE = 100_000;
|
||||
const NORMALIZED_EPSILON = 1e-9;
|
||||
|
||||
export class WorkspaceLayoutContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "WorkspaceLayoutContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceLayoutApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status = 0) {
|
||||
super(message);
|
||||
this.name = "WorkspaceLayoutApiError";
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
get conflict(): boolean {
|
||||
return this.status === 409 || this.status === 412;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, field: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть объектом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: ReadonlySet<string>,
|
||||
field: string,
|
||||
): void {
|
||||
const actual = Object.keys(value);
|
||||
const unknown = actual.filter((key) => !expected.has(key));
|
||||
const missing = [...expected].filter((key) => !(key in value));
|
||||
if (unknown.length || missing.length) {
|
||||
const details = [
|
||||
unknown.length ? `неизвестные: ${unknown.sort().join(", ")}` : "",
|
||||
missing.length ? `отсутствуют: ${missing.sort().join(", ")}` : "",
|
||||
].filter(Boolean).join("; ");
|
||||
throw new WorkspaceLayoutContractError(`${field}: неверная схема (${details}).`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireBoolean(value: unknown, field: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireFiniteInRange(
|
||||
value: unknown,
|
||||
field: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
{ integer = false, minimumExclusive = false }: { integer?: boolean; minimumExclusive?: boolean } = {},
|
||||
): number {
|
||||
const belowMinimum = minimumExclusive
|
||||
? typeof value === "number" && value <= minimum
|
||||
: typeof value === "number" && value < minimum;
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value) ||
|
||||
belowMinimum ||
|
||||
value > maximum ||
|
||||
(integer && !Number.isInteger(value))
|
||||
) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле ${field} должно быть конечным ${integer ? "целым " : ""}числом в допустимом диапазоне.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireStableId(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !SAFE_STABLE_ID.test(value)) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле ${field} должно содержать безопасный стабильный идентификатор источника.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeViewport(value: unknown, field = "viewport_size"): ObservationViewportSize {
|
||||
const record = requireRecord(value, field);
|
||||
assertExactKeys(record, VIEWPORT_KEYS, field);
|
||||
return {
|
||||
width: requireFiniteInRange(record.width, `${field}.width`, 1, MAX_VIEWPORT_EDGE),
|
||||
height: requireFiniteInRange(record.height, `${field}.height`, 1, MAX_VIEWPORT_EDGE),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateObservationViewportSize(
|
||||
value: ObservationViewportSize,
|
||||
): ObservationViewportSize {
|
||||
return decodeViewport(value, "viewport");
|
||||
}
|
||||
|
||||
function decodeNormalizedRect(
|
||||
value: unknown,
|
||||
field: string,
|
||||
): NormalizedObservationWindowRect {
|
||||
const record = requireRecord(value, field);
|
||||
assertExactKeys(record, RECT_KEYS, field);
|
||||
const rect = {
|
||||
x: requireFiniteInRange(record.x, `${field}.x`, 0, 1),
|
||||
y: requireFiniteInRange(record.y, `${field}.y`, 0, 1),
|
||||
width: requireFiniteInRange(record.width, `${field}.width`, 0, 1, { minimumExclusive: true }),
|
||||
height: requireFiniteInRange(record.height, `${field}.height`, 0, 1, { minimumExclusive: true }),
|
||||
};
|
||||
if (
|
||||
rect.x + rect.width > 1 + NORMALIZED_EPSILON ||
|
||||
rect.y + rect.height > 1 + NORMALIZED_EPSILON
|
||||
) {
|
||||
throw new WorkspaceLayoutContractError(`${field} выходит за нормализованные границы viewport.`);
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
|
||||
function decodeStableIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_SOURCES) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле visible_source_ids должно быть массивом не более ${MAX_SOURCES} элементов.`,
|
||||
);
|
||||
}
|
||||
const result = value.map((entry, index) => requireStableId(entry, `visible_source_ids[${index}]`));
|
||||
if (new Set(result).size !== result.length) {
|
||||
throw new WorkspaceLayoutContractError("Поле visible_source_ids содержит повторяющиеся id.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeWindowRects(value: unknown): Record<string, NormalizedObservationWindowRect> {
|
||||
const record = requireRecord(value, "window_rects");
|
||||
if (Object.keys(record).length > MAX_SOURCES) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле window_rects содержит более ${MAX_SOURCES} элементов.`,
|
||||
);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(record).map(([sourceId, rect]) => [
|
||||
requireStableId(sourceId, "window_rects key"),
|
||||
decodeNormalizedRect(rect, `window_rects.${sourceId}`),
|
||||
]));
|
||||
}
|
||||
|
||||
function requireEnum<T extends string>(
|
||||
value: unknown,
|
||||
allowed: ReadonlySet<T>,
|
||||
field: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !allowed.has(value as T)) {
|
||||
throw new WorkspaceLayoutContractError(`Поле ${field} содержит неизвестное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function decodeSceneSettings(value: unknown): SceneSettings {
|
||||
const record = requireRecord(value, "scene_settings");
|
||||
assertExactKeys(record, SCENE_KEYS, "scene_settings");
|
||||
if (typeof record.custom_color !== "string" || !HEX_COLOR.test(record.custom_color)) {
|
||||
throw new WorkspaceLayoutContractError("Поле scene_settings.custom_color должно быть цветом #RRGGBB.");
|
||||
}
|
||||
return {
|
||||
projection: requireEnum(record.projection, PROJECTIONS, "scene_settings.projection"),
|
||||
pointSize: requireFiniteInRange(record.point_size, "scene_settings.point_size", 0.1, 32),
|
||||
colorMode: requireEnum(record.color_mode, COLOR_MODES, "scene_settings.color_mode"),
|
||||
palette: requireEnum(record.palette, PALETTES, "scene_settings.palette"),
|
||||
customColor: record.custom_color,
|
||||
accumulationSeconds: requireFiniteInRange(
|
||||
record.accumulation_seconds,
|
||||
"scene_settings.accumulation_seconds",
|
||||
0,
|
||||
3_600,
|
||||
),
|
||||
showPoints: requireBoolean(record.show_points, "scene_settings.show_points"),
|
||||
showTrajectory: requireBoolean(record.show_trajectory, "scene_settings.show_trajectory"),
|
||||
showGrid: requireBoolean(record.show_grid, "scene_settings.show_grid"),
|
||||
showLabels: requireBoolean(record.show_labels, "scene_settings.show_labels"),
|
||||
showCameraFrustums: requireBoolean(
|
||||
record.show_camera_frustums,
|
||||
"scene_settings.show_camera_frustums",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeToolWindows(value: unknown): ObservationToolWindows {
|
||||
const record = requireRecord(value, "tool_windows");
|
||||
assertExactKeys(record, TOOL_WINDOW_KEYS, "tool_windows");
|
||||
if (!Array.isArray(record.order) || record.order.length !== TOOL_WINDOW_IDS.size) {
|
||||
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно содержать три окна.");
|
||||
}
|
||||
const order = record.order.map((entry, index) =>
|
||||
requireEnum(entry, TOOL_WINDOW_IDS, `tool_windows.order[${index}]`));
|
||||
if (new Set(order).size !== TOOL_WINDOW_IDS.size) {
|
||||
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно быть перестановкой окон.");
|
||||
}
|
||||
return {
|
||||
sourcesOpen: requireBoolean(record.sources_open, "tool_windows.sources_open"),
|
||||
displayOpen: requireBoolean(record.display_open, "tool_windows.display_open"),
|
||||
layersOpen: requireBoolean(record.layers_open, "tool_windows.layers_open"),
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeObservationWorkspaceLayoutProfile(
|
||||
value: unknown,
|
||||
): ObservationWorkspaceLayoutProfile {
|
||||
const record = requireRecord(value, "workspace layout");
|
||||
assertExactKeys(record, PROFILE_KEYS, "workspace layout");
|
||||
if (record.version !== OBSERVATION_WORKSPACE_LAYOUT_VERSION) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Неподдерживаемая версия workspace layout: ${String(record.version)}.`,
|
||||
);
|
||||
}
|
||||
if (record.workspace_id !== OBSERVATION_WORKSPACE_ID) {
|
||||
throw new WorkspaceLayoutContractError("Профиль относится к другой рабочей поверхности.");
|
||||
}
|
||||
const visibleSourceIds = decodeStableIds(record.visible_source_ids);
|
||||
const activeFloatingSourceId = record.active_floating_source_id === null
|
||||
? null
|
||||
: requireStableId(record.active_floating_source_id, "active_floating_source_id");
|
||||
if (activeFloatingSourceId && !visibleSourceIds.includes(activeFloatingSourceId)) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
"Активное плавающее окно должно относиться к видимому источнику.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
revision: requireFiniteInRange(record.revision, "revision", 0, Number.MAX_SAFE_INTEGER, {
|
||||
integer: true,
|
||||
}),
|
||||
workspaceId: OBSERVATION_WORKSPACE_ID,
|
||||
sceneSettings: decodeSceneSettings(record.scene_settings),
|
||||
toolWindows: decodeToolWindows(record.tool_windows),
|
||||
visibleSourceIds,
|
||||
activeFloatingSourceId,
|
||||
windowRects: decodeWindowRects(record.window_rects),
|
||||
viewportSize: decodeViewport(record.viewport_size),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeObservationWorkspaceLayoutProfile(
|
||||
profile: ObservationWorkspaceLayoutProfile,
|
||||
): WireProfile {
|
||||
const wire: WireProfile = {
|
||||
version: profile.version,
|
||||
revision: profile.revision,
|
||||
workspace_id: profile.workspaceId,
|
||||
scene_settings: {
|
||||
projection: profile.sceneSettings.projection,
|
||||
point_size: profile.sceneSettings.pointSize,
|
||||
color_mode: profile.sceneSettings.colorMode,
|
||||
palette: profile.sceneSettings.palette,
|
||||
custom_color: profile.sceneSettings.customColor,
|
||||
accumulation_seconds: profile.sceneSettings.accumulationSeconds,
|
||||
show_points: profile.sceneSettings.showPoints,
|
||||
show_trajectory: profile.sceneSettings.showTrajectory,
|
||||
show_grid: profile.sceneSettings.showGrid,
|
||||
show_labels: profile.sceneSettings.showLabels,
|
||||
show_camera_frustums: profile.sceneSettings.showCameraFrustums,
|
||||
},
|
||||
tool_windows: {
|
||||
sources_open: profile.toolWindows.sourcesOpen,
|
||||
display_open: profile.toolWindows.displayOpen,
|
||||
layers_open: profile.toolWindows.layersOpen,
|
||||
order: [...profile.toolWindows.order],
|
||||
},
|
||||
visible_source_ids: [...profile.visibleSourceIds],
|
||||
active_floating_source_id: profile.activeFloatingSourceId,
|
||||
window_rects: Object.fromEntries(
|
||||
Object.entries(profile.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
|
||||
),
|
||||
viewport_size: { ...profile.viewportSize },
|
||||
};
|
||||
// The decoder is the single runtime schema authority for inbound and outbound documents.
|
||||
decodeObservationWorkspaceLayoutProfile(wire);
|
||||
return wire;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
export function normalizeObservationWindowRect(
|
||||
rect: ObservationWindowRect,
|
||||
viewport: ObservationViewportSize,
|
||||
): NormalizedObservationWindowRect {
|
||||
const safeViewport = decodeViewport(viewport, "viewport");
|
||||
for (const [field, value] of Object.entries(rect)) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new WorkspaceLayoutContractError(`Поле rect.${field} должно быть конечным числом.`);
|
||||
}
|
||||
}
|
||||
const x = clamp(rect.x, 0, Math.max(0, safeViewport.width - 1));
|
||||
const y = clamp(rect.y, 0, Math.max(0, safeViewport.height - 1));
|
||||
const width = clamp(rect.width, 1, Math.max(1, safeViewport.width - x));
|
||||
const height = clamp(rect.height, 1, Math.max(1, safeViewport.height - y));
|
||||
return {
|
||||
x: x / safeViewport.width,
|
||||
y: y / safeViewport.height,
|
||||
width: width / safeViewport.width,
|
||||
height: height / safeViewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function denormalizeObservationWindowRect(
|
||||
rect: NormalizedObservationWindowRect,
|
||||
viewport: ObservationViewportSize,
|
||||
): ObservationWindowRect {
|
||||
const normalized = decodeNormalizedRect(rect, "rect");
|
||||
const safeViewport = decodeViewport(viewport, "viewport");
|
||||
return {
|
||||
x: normalized.x * safeViewport.width,
|
||||
y: normalized.y * safeViewport.height,
|
||||
width: normalized.width * safeViewport.width,
|
||||
height: normalized.height * safeViewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateObservationLayoutSnapshot(
|
||||
snapshot: ObservationLayoutSnapshot,
|
||||
): ObservationLayoutSnapshot {
|
||||
const wire = {
|
||||
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
revision: 0,
|
||||
workspace_id: OBSERVATION_WORKSPACE_ID,
|
||||
scene_settings: {
|
||||
projection: "3d",
|
||||
point_size: 1,
|
||||
color_mode: "intensity",
|
||||
palette: "turbo",
|
||||
custom_color: "#ffffff",
|
||||
accumulation_seconds: 0,
|
||||
show_points: true,
|
||||
show_trajectory: true,
|
||||
show_grid: true,
|
||||
show_labels: false,
|
||||
show_camera_frustums: true,
|
||||
},
|
||||
tool_windows: {
|
||||
sources_open: false,
|
||||
display_open: false,
|
||||
layers_open: false,
|
||||
order: ["sources", "display", "layers"],
|
||||
},
|
||||
visible_source_ids: [...snapshot.visibleSourceIds],
|
||||
active_floating_source_id: snapshot.activeFloatingSourceId,
|
||||
window_rects: snapshot.windowRects,
|
||||
viewport_size: snapshot.viewportSize,
|
||||
};
|
||||
const decoded = decodeObservationWorkspaceLayoutProfile(wire);
|
||||
return {
|
||||
visibleSourceIds: decoded.visibleSourceIds,
|
||||
activeFloatingSourceId: decoded.activeFloatingSourceId,
|
||||
windowRects: decoded.windowRects,
|
||||
viewportSize: decoded.viewportSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectObservationLayoutSnapshot(
|
||||
snapshot: ObservationLayoutSnapshot,
|
||||
knownSourceIds: ReadonlySet<string>,
|
||||
viewport: ObservationViewportSize,
|
||||
): ProjectedObservationLayout {
|
||||
const validated = validateObservationLayoutSnapshot(snapshot);
|
||||
const targetViewport = decodeViewport(viewport, "viewport");
|
||||
const visibleSourceIds = validated.visibleSourceIds.filter((sourceId) => knownSourceIds.has(sourceId));
|
||||
const visibleSet = new Set(visibleSourceIds);
|
||||
return {
|
||||
visibleSourceIds,
|
||||
activeFloatingSourceId: validated.activeFloatingSourceId && visibleSet.has(validated.activeFloatingSourceId)
|
||||
? validated.activeFloatingSourceId
|
||||
: null,
|
||||
windowRects: Object.fromEntries(
|
||||
Object.entries(validated.windowRects)
|
||||
.filter(([sourceId]) => knownSourceIds.has(sourceId))
|
||||
.map(([sourceId, rect]) => [
|
||||
sourceId,
|
||||
denormalizeObservationWindowRect(rect, targetViewport),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
|
||||
return body.detail.trim();
|
||||
}
|
||||
} catch {
|
||||
// A non-JSON error body is represented by the stable fallback.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function fetchObservationWorkspaceLayoutProfile({
|
||||
fetcher = fetch,
|
||||
}: { fetcher?: WorkspaceLayoutFetch } = {}): Promise<ObservationWorkspaceLayoutProfile | null> {
|
||||
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new WorkspaceLayoutApiError(
|
||||
await responseMessage(response, "Не удалось загрузить профиль рабочей поверхности."),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new WorkspaceLayoutContractError("Сервер вернул профиль не в формате JSON.");
|
||||
}
|
||||
return decodeObservationWorkspaceLayoutProfile(body);
|
||||
}
|
||||
|
||||
export async function saveObservationWorkspaceLayoutProfile(
|
||||
profile: ObservationWorkspaceLayoutProfile,
|
||||
{ fetcher = fetch }: { fetcher?: WorkspaceLayoutFetch } = {},
|
||||
): Promise<ObservationWorkspaceLayoutProfile> {
|
||||
const wire = encodeObservationWorkspaceLayoutProfile(profile);
|
||||
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"If-Match": `"${profile.revision}"`,
|
||||
},
|
||||
body: JSON.stringify(wire),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new WorkspaceLayoutApiError(
|
||||
await responseMessage(response, response.status === 409 || response.status === 412
|
||||
? "Профиль уже изменён в другом окне. Обновите данные и повторите сохранение."
|
||||
: "Не удалось сохранить профиль рабочей поверхности."),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new WorkspaceLayoutContractError("Сервер не вернул сохранённый профиль в формате JSON.");
|
||||
}
|
||||
const saved = decodeObservationWorkspaceLayoutProfile(body);
|
||||
if (saved.revision <= profile.revision) {
|
||||
throw new WorkspaceLayoutContractError("Сервер не увеличил revision сохранённого профиля.");
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
|
@ -123,6 +123,16 @@ export type ObservationSourceDelivery =
|
|||
kind: "video-url" | "image-url";
|
||||
url: string;
|
||||
mediaType?: string | null;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "recorded-fmp4-manifest";
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
manifestGenerationSha256: string;
|
||||
byteLength: number;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
};
|
||||
|
||||
export interface ObservationSourceActivation {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
export interface LatestAsyncCommitter<T> {
|
||||
enqueue: (value: T) => void;
|
||||
hasPending: () => boolean;
|
||||
isBusy: () => boolean;
|
||||
waitForIdle: () => Promise<void>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface LatestAsyncCommitResult<T> {
|
||||
value: T;
|
||||
applied: boolean;
|
||||
superseded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an expensive settings commit while retaining only the newest
|
||||
* value submitted during the active request. This keeps slider and color
|
||||
* interactions from creating an unbounded backend queue.
|
||||
*/
|
||||
export function createLatestAsyncCommitter<T>({
|
||||
commit,
|
||||
onSettled,
|
||||
}: {
|
||||
commit: (value: T) => Promise<boolean>;
|
||||
onSettled?: (result: LatestAsyncCommitResult<T>) => void;
|
||||
}): LatestAsyncCommitter<T> {
|
||||
let pending: T | undefined;
|
||||
let running = false;
|
||||
let disposed = false;
|
||||
const idleWaiters = new Set<() => void>();
|
||||
|
||||
const settleIdleWaiters = () => {
|
||||
if (running || pending !== undefined) return;
|
||||
for (const resolve of idleWaiters) resolve();
|
||||
idleWaiters.clear();
|
||||
};
|
||||
|
||||
const drain = async () => {
|
||||
if (running || disposed) return;
|
||||
running = true;
|
||||
|
||||
try {
|
||||
while (!disposed && pending !== undefined) {
|
||||
const value = pending;
|
||||
pending = undefined;
|
||||
|
||||
let applied = false;
|
||||
try {
|
||||
applied = await commit(value);
|
||||
} catch {
|
||||
applied = false;
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
onSettled?.({
|
||||
value,
|
||||
applied,
|
||||
superseded: pending !== undefined,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
if (!disposed && pending !== undefined) {
|
||||
void drain();
|
||||
} else {
|
||||
settleIdleWaiters();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue(value) {
|
||||
if (disposed) return;
|
||||
pending = value;
|
||||
void drain();
|
||||
},
|
||||
hasPending: () => pending !== undefined,
|
||||
isBusy: () => running || pending !== undefined,
|
||||
waitForIdle() {
|
||||
if (!running && pending === undefined) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => idleWaiters.add(resolve));
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
pending = undefined;
|
||||
settleIdleWaiters();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
.observation-header-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.workspace-layout-feedback {
|
||||
display: inline-flex;
|
||||
max-width: 15rem;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.workspace-layout-feedback[data-error="true"] {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.observation-session-select__trigger {
|
||||
display: inline-flex;
|
||||
min-width: 15rem;
|
||||
height: 2.75rem;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.055);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger:hover,
|
||||
.observation-session-select__trigger[data-active="true"] {
|
||||
background: rgb(255 255 255 / 0.095);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-session-select__trigger > span {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger > small {
|
||||
display: grid;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observation-session-menu {
|
||||
overflow: hidden;
|
||||
font-family: var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
.observation-session-menu__content,
|
||||
.observation-session-menu__list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.observation-session-menu__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 0.9rem 0.6rem;
|
||||
}
|
||||
|
||||
.observation-session-menu__head span,
|
||||
.observation-session-menu__head strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.observation-session-menu__head strong {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
}
|
||||
|
||||
.observation-session-menu__head button {
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.055);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-menu__head button:hover {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-session-menu__list {
|
||||
max-height: min(26rem, 60vh);
|
||||
gap: 0.2rem;
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-session-option {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-session-option i {
|
||||
display: block;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observation-session-option i[data-session-visual-state="ready"],
|
||||
.observation-session-option i[data-session-visual-state="processing"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.observation-session-option i[data-session-visual-state="processing"] {
|
||||
animation: observation-session-processing 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.observation-session-option i[data-session-visual-state="error"] {
|
||||
background: var(--nodedc-text-muted);
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
@keyframes observation-session-processing {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.observation-session-option i[data-session-visual-state="processing"] {
|
||||
animation: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
.observation-session-option__state {
|
||||
max-width: 9.5rem;
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-session-menu__empty {
|
||||
display: grid;
|
||||
min-height: 9rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observation-session-menu__empty strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.observation-session-menu__empty span {
|
||||
max-width: 24rem;
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observation-session-menu__error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem 0.9rem 0.8rem;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.observation-session-menu__error > span {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.observation-session-menu__error button {
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.4rem 0.65rem;
|
||||
font: inherit;
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.workspace-layout-feedback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger {
|
||||
min-width: 2.75rem;
|
||||
width: 2.75rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger > span,
|
||||
.observation-session-select__trigger > small,
|
||||
.observation-session-select__trigger > svg:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +191,32 @@ i[data-availability="error"] {
|
|||
background: #050608;
|
||||
}
|
||||
|
||||
.recorded-media-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #070809;
|
||||
}
|
||||
|
||||
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.recorded-media-player__notice {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
color: rgba(247, 248, 244, 0.72);
|
||||
background: #070809;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mse-fmp4-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
|
@ -316,11 +342,8 @@ i[data-availability="error"] {
|
|||
}
|
||||
|
||||
.observation-timeline {
|
||||
display: grid;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border: 0;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(9 10 13 / 0.78);
|
||||
|
|
@ -328,22 +351,73 @@ i[data-availability="error"] {
|
|||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.observation-timeline__track {
|
||||
height: 0.3rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
.observation-timeline[data-accumulation="true"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.observation-timeline__track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
.observation-timeline__playback {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation span,
|
||||
.observation-timeline__accumulation code {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation code {
|
||||
min-width: 2.65rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.observation-timeline__track {
|
||||
width: 100%;
|
||||
height: 0.3rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-timeline__track::-webkit-slider-thumb {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track[data-disabled="true"] {
|
||||
.observation-timeline__track::-moz-range-thumb {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track:disabled {
|
||||
opacity: 0.46;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.observation-timeline__meta {
|
||||
|
|
@ -364,11 +438,17 @@ i[data-availability="error"] {
|
|||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.observation-timeline__follow:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-timeline__follow::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
|
|
@ -514,11 +594,15 @@ i[data-availability="error"] {
|
|||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline {
|
||||
.observation-timeline[data-accumulation="true"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.observation-timeline__playback {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-timeline > .nodedc-button:first-child,
|
||||
.observation-timeline__playback > .nodedc-button:first-child,
|
||||
.observation-timeline__meta {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@
|
|||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.station-label,
|
||||
.control-station .nodedc-header__profile-button {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,3 @@
|
|||
.station-label {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.61rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.13em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.api-dot {
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
|
|
|
|||
|
|
@ -45,17 +45,34 @@
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas[data-presented="false"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.recorded-session-preloaders {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: -10000px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rerun-viewport {
|
||||
--rerun-tab-bar-height: 24px;
|
||||
--rerun-native-chrome-height: 72px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Rerun WebViewer 0.34.1 draws its selected recording tab inside WASM.
|
||||
The native panels are already disabled; crop only that fixed tab row. */
|
||||
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
|
||||
panels are overridden: the native top row, recording tab and view tab.
|
||||
They are drawn inside WASM and cannot be styled independently, so crop the
|
||||
fixed native chrome while keeping the actual 3D viewport full-height. */
|
||||
.rerun-viewport__canvas {
|
||||
top: calc(-1 * var(--rerun-tab-bar-height));
|
||||
top: calc(-1 * var(--rerun-native-chrome-height));
|
||||
bottom: auto;
|
||||
height: calc(100% + var(--rerun-tab-bar-height));
|
||||
height: calc(100% + var(--rerun-native-chrome-height));
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas canvas {
|
||||
|
|
@ -65,8 +82,9 @@
|
|||
}
|
||||
|
||||
.rerun-viewport:is([data-status="loading"], [data-status="error"])
|
||||
.rerun-viewport__canvas > div {
|
||||
.rerun-viewport__canvas {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ import {
|
|||
} from "../components/ObservationSources";
|
||||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
|
||||
import type {
|
||||
BackendStatus,
|
||||
|
|
@ -22,6 +28,10 @@ import type {
|
|||
} from "../core/runtime/contracts";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
rerunPresentationStatus,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
} from "../components/RerunViewport";
|
||||
|
|
@ -95,7 +105,12 @@ export interface WorkspaceRendererProps {
|
|||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
recordedReplay: ObservationSessionReplayLaunch | null;
|
||||
recordedSessionAdmission: RecordedSessionAdmissionController | null;
|
||||
sceneSettings: SceneSettings;
|
||||
accumulationSeconds: number;
|
||||
onAccumulationChange: (value: number) => void;
|
||||
onAccumulationCommit: () => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
navigation: WorkspaceNavigation;
|
||||
}
|
||||
|
|
@ -248,13 +263,27 @@ function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
|||
function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
recordedReplay,
|
||||
recordedSessionAdmission,
|
||||
sceneSettings,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
observationLayout,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
const recordedPlaybackReady = !recordedSource ||
|
||||
(recordedSessionGate === "ready" &&
|
||||
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
|
||||
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
|
|
@ -277,28 +306,83 @@ function SpatialWorkspace({
|
|||
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const presentedViewerStatus = rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
setViewerMessage(message ?? "");
|
||||
}, []);
|
||||
if (recordedSessionAdmission) {
|
||||
recordedSessionAdmission.reportSpatial(
|
||||
recordedSessionAdmission.key,
|
||||
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
|
||||
);
|
||||
}
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
|
||||
const onRecordedAdmissionChange = useCallback((
|
||||
sourceId: string,
|
||||
next: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
if (!recordedSessionAdmission) return;
|
||||
if (next.admissionKey !== recordedSessionAdmission.key) return;
|
||||
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
||||
}, [recordedSessionAdmission]);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
const onPlaybackChange = useCallback(
|
||||
(next: RerunPlaybackState | null) => setPlaybackState(next),
|
||||
[],
|
||||
);
|
||||
const onPlaybackControllerChange = useCallback(
|
||||
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||
setViewerStatus("idle");
|
||||
setViewerMessage("");
|
||||
setSelection(null);
|
||||
setPlaybackState(null);
|
||||
setPlaybackController(null);
|
||||
}, [pointCloudVisible, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const publishViewportSize = () => {
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
if (bounds.width < 1 || bounds.height < 1) return;
|
||||
observationLayout.setViewportSize({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
};
|
||||
publishViewportSize();
|
||||
const observer = new ResizeObserver(publishViewportSize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [observationLayout.setViewportSize]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
}[viewerStatus];
|
||||
}[presentedViewerStatus];
|
||||
|
||||
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
|
||||
const viewerStatusTone = presentedViewerStatus === "ready"
|
||||
? "success"
|
||||
: presentedViewerStatus === "error"
|
||||
? "danger"
|
||||
: "neutral";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -331,9 +415,21 @@ function SpatialWorkspace({
|
|||
{sourceUrl.trim() && pointCloudVisible ? (
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
followLive={state?.sourceMode === "live"}
|
||||
recordedArtifact={recordedSource ? recordedReplay : null}
|
||||
followLive={!recordedSource && state?.sourceMode === "live"}
|
||||
autoplayWhenReady={recordedSource}
|
||||
presentationGate={recordedSessionGate}
|
||||
expectedTimelineStartSeconds={recordedSource
|
||||
? state?.observationTimeline?.range?.startSeconds
|
||||
: undefined}
|
||||
expectedTimelineEndSeconds={recordedSource
|
||||
? state?.observationTimeline?.range?.endSeconds
|
||||
: undefined}
|
||||
sceneSettings={sceneSettings}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlaybackControllerChange={onPlaybackControllerChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
|
|
@ -415,13 +511,26 @@ function SpatialWorkspace({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized ? (
|
||||
{!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={viewerStatus === "ready"}
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, 1 + visibleMediaSources.length)}
|
||||
mode={timeline?.mode}
|
||||
seekable={timeline?.seekable}
|
||||
mode={recordedSource && playbackState?.rangeNs
|
||||
? "recorded"
|
||||
: timeline?.mode}
|
||||
seekable={recordedSource && playbackState?.rangeNs
|
||||
? true
|
||||
: timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
rangeNs={playbackState?.rangeNs}
|
||||
currentNs={playbackState?.currentNs}
|
||||
playing={playbackState?.playing}
|
||||
onSeek={playbackController?.seek}
|
||||
onPlayingChange={playbackController?.setPlaying}
|
||||
onJumpToEnd={playbackController?.jumpToEnd}
|
||||
accumulationSeconds={accumulationSeconds}
|
||||
onAccumulationChange={onAccumulationChange}
|
||||
onAccumulationCommit={onAccumulationCommit}
|
||||
className="scene-timeline"
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -440,6 +549,14 @@ function SpatialWorkspace({
|
|||
onMaximizedChange={(maximized) =>
|
||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||
playback={recordedSource && playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: playbackState.playing,
|
||||
} : null}
|
||||
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
onClose={() => {
|
||||
if (observationLayout.pendingSourceIds.has(source.id)) return;
|
||||
observationLayout.setFloatingMaximized(source.id, false);
|
||||
|
|
@ -447,6 +564,28 @@ function SpatialWorkspace({
|
|||
}}
|
||||
/>
|
||||
)) : null}
|
||||
{recordedSource ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{mediaSources.filter((source) => (
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
(pointCloudFocused || !observationLayout.visibleSourceIds.has(source.id)) &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
key={source.id}
|
||||
source={source}
|
||||
playback={playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: false,
|
||||
} : null}
|
||||
prepareRecorded
|
||||
recordedSessionGate="loading"
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="spatial-contract-strip">
|
||||
|
|
@ -470,6 +609,10 @@ function CameraSourceCard({
|
|||
onToggle,
|
||||
onFocus,
|
||||
onClose,
|
||||
prepareRecorded,
|
||||
recordedSessionGate,
|
||||
recordedAdmissionKey,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
focused: boolean;
|
||||
|
|
@ -479,6 +622,13 @@ function CameraSourceCard({
|
|||
onToggle: () => void;
|
||||
onFocus: () => void;
|
||||
onClose: () => void;
|
||||
prepareRecorded: boolean;
|
||||
recordedSessionGate: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey: string | null;
|
||||
onRecordedAdmissionChange: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const deliveryActive = Boolean(
|
||||
(source.delivery || source.previewUrl) &&
|
||||
|
|
@ -542,7 +692,13 @@ function CameraSourceCard({
|
|||
</div>
|
||||
</header>
|
||||
<div className="camera-slot__body">
|
||||
<ObservationMedia source={source} />
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
prepareRecorded={prepareRecorded}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
</div>
|
||||
<footer>
|
||||
<span>{source.description}</span>
|
||||
|
|
@ -552,13 +708,36 @@ function CameraSourceCard({
|
|||
);
|
||||
}
|
||||
|
||||
function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRendererProps) {
|
||||
function CamerasWorkspace({
|
||||
definition,
|
||||
state,
|
||||
observationLayout,
|
||||
recordedReplay,
|
||||
recordedSessionAdmission,
|
||||
}: WorkspaceRendererProps) {
|
||||
const sources = (state?.observationSources ?? []).filter(
|
||||
(source) => source.modality === "video" || source.modality === "image" || source.modality === "depth",
|
||||
);
|
||||
const focusedSource = sources.find((source) => source.id === observationLayout.focusedSourceId) ?? null;
|
||||
const timeline = state?.observationTimeline;
|
||||
const displayedSources = focusedSource ? [focusedSource] : sources;
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedReplay
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
const onRecordedAdmissionChange = useCallback((
|
||||
sourceId: string,
|
||||
next: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
if (!recordedSessionAdmission) return;
|
||||
if (next.admissionKey !== recordedSessionAdmission.key) return;
|
||||
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedReplay) return true;
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
||||
}, [recordedReplay, recordedSessionAdmission]);
|
||||
return (
|
||||
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
||||
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
||||
|
|
@ -587,6 +766,10 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
|||
observationLayout.setFocusedSourceId(null);
|
||||
void observationLayout.hideSource(source.id);
|
||||
}}
|
||||
prepareRecorded={shouldPrepareRecordedSource(source.id)}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
)) : (
|
||||
<div className="camera-grid__empty">
|
||||
|
|
@ -596,7 +779,25 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ObservationTimeline
|
||||
{recordedReplay ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{sources.filter((source) => (
|
||||
!displayedSources.some(({ id }) => id === source.id) &&
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
key={source.id}
|
||||
source={source}
|
||||
prepareRecorded
|
||||
recordedSessionGate="loading"
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!recordedReplay || recordedSessionGate === "ready" ? <ObservationTimeline
|
||||
active={sources.some((source) =>
|
||||
source.availability === "streaming" &&
|
||||
(!source.activation || source.activation.selected))}
|
||||
|
|
@ -606,7 +807,7 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
|||
seekable={timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
className="camera-timeline"
|
||||
/>
|
||||
/> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,832 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let decodeObservationSessionCatalog;
|
||||
let decodeObservationSessionReplay;
|
||||
let decodeObservationSessionPreparation;
|
||||
let fetchObservationSessionCatalog;
|
||||
let replayObservationSession;
|
||||
let fetchObservationSessionPreparation;
|
||||
let cancelObservationSessionPreparation;
|
||||
let ObservationSessionApiError;
|
||||
let ObservationSessionContractError;
|
||||
let decodeObservationRecordedMediaManifest;
|
||||
let createObservationReplayCoordinator;
|
||||
let resolveObservationSessionReplay;
|
||||
let waitForObservationReplayPreparation;
|
||||
let storeObservationReplayPreparation;
|
||||
let loadObservationReplayPreparation;
|
||||
let observationSessionVisualState;
|
||||
let observationSessionVisualLabel;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
decodeObservationSessionCatalog,
|
||||
decodeObservationSessionReplay,
|
||||
decodeObservationSessionPreparation,
|
||||
fetchObservationSessionCatalog,
|
||||
replayObservationSession,
|
||||
fetchObservationSessionPreparation,
|
||||
cancelObservationSessionPreparation,
|
||||
ObservationSessionApiError,
|
||||
ObservationSessionContractError,
|
||||
decodeObservationRecordedMediaManifest,
|
||||
} = await server.ssrLoadModule("/src/core/observation/sessionArchive.ts"));
|
||||
({
|
||||
createObservationReplayCoordinator,
|
||||
resolveObservationSessionReplay,
|
||||
waitForObservationReplayPreparation,
|
||||
storeObservationReplayPreparation,
|
||||
loadObservationReplayPreparation,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/core/observation/useObservationSessions.ts",
|
||||
));
|
||||
({ observationSessionVisualState, observationSessionVisualLabel } = await server.ssrLoadModule(
|
||||
"/src/components/ObservationSessionSelect.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function session(overrides = {}) {
|
||||
return {
|
||||
id: "20260716T205632Z_viewer_live",
|
||||
label: "K1 · наблюдение 16 июля",
|
||||
started_at_utc: "2026-07-16T20:56:32.635Z",
|
||||
completed_at_utc: "2026-07-16T21:20:43.379Z",
|
||||
status: "ready",
|
||||
modalities: ["point-cloud", "pose"],
|
||||
duration_seconds: 1_450.744,
|
||||
replayable: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function replay(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
const sourceUrl = overrides.source_url ??
|
||||
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording.rrd`;
|
||||
const sha256 = overrides.sha256 ?? "a".repeat(64);
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-replay/v2",
|
||||
launch: {
|
||||
kind: "rerun-recording",
|
||||
session_id: sessionId,
|
||||
source_url: sourceUrl,
|
||||
viewer_source_url: overrides.viewer_source_url ?? `${sourceUrl}?generation=${sha256}`,
|
||||
media_type: "application/vnd.rerun.rrd",
|
||||
timeline: "session_time",
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 1_450.744,
|
||||
seekable: true,
|
||||
byte_length: 123_456,
|
||||
sha256,
|
||||
playback: { speed: 1, loop: false },
|
||||
media_sources: [],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function preparation(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-preparation/v1",
|
||||
preparation: {
|
||||
preparation_id: "prepare-20260717T131400Z",
|
||||
session_id: sessionId,
|
||||
state: "queued",
|
||||
progress: null,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
status_url: `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording-preparation`,
|
||||
cancellable: true,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const preparationEtag = '"prepare-20260717T131400Z"';
|
||||
|
||||
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
|
||||
const catalog = decodeObservationSessionCatalog({ items: [session()] });
|
||||
|
||||
assert.deepEqual(catalog.items, [{
|
||||
id: "20260716T205632Z_viewer_live",
|
||||
label: "K1 · наблюдение 16 июля",
|
||||
startedAtUtc: "2026-07-16T20:56:32.635Z",
|
||||
completedAtUtc: "2026-07-16T21:20:43.379Z",
|
||||
status: "ready",
|
||||
modalities: ["point-cloud", "pose"],
|
||||
durationSeconds: 1_450.744,
|
||||
replayable: true,
|
||||
preparation: null,
|
||||
}]);
|
||||
assert.equal("raw_capture" in catalog.items[0], false);
|
||||
assert.equal("path" in catalog.items[0], false);
|
||||
});
|
||||
|
||||
test("session catalog exposes authoritative background preparation state", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
preparation: {
|
||||
preparation_id: "prepare-catalog-1",
|
||||
state: "exporting",
|
||||
progress: 0.42,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
cancellable: true,
|
||||
retryable: false,
|
||||
},
|
||||
})],
|
||||
});
|
||||
assert.deepEqual(catalog.items[0].preparation, {
|
||||
preparationId: "prepare-catalog-1",
|
||||
state: "exporting",
|
||||
progress: 0.42,
|
||||
updatedAtUtc: "2026-07-17T10:14:00Z",
|
||||
cancellable: true,
|
||||
retryable: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("saved-session rows use only authoritative ready, processing and error states", () => {
|
||||
const makeDecoded = (state, overrides = {}) => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
status: "interrupted",
|
||||
preparation: state === null ? null : {
|
||||
preparation_id: `prepare-${state}`,
|
||||
state,
|
||||
progress: state === "ready" ? 1 : 0.5,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
cancellable: ["queued", "validating", "exporting", "finalizing"].includes(state),
|
||||
retryable: state === "failed",
|
||||
...(state === "failed" ? { error: "export failed" } : {}),
|
||||
},
|
||||
...overrides,
|
||||
})],
|
||||
}).items[0];
|
||||
|
||||
assert.equal(observationSessionVisualState(makeDecoded("ready")), "ready");
|
||||
for (const state of ["queued", "validating", "exporting", "finalizing"]) {
|
||||
assert.equal(observationSessionVisualState(makeDecoded(state)), "processing");
|
||||
}
|
||||
assert.equal(observationSessionVisualState(makeDecoded("failed")), "error");
|
||||
assert.equal(observationSessionVisualState(makeDecoded("cancelled")), "error");
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("failed", { status: "recording" })),
|
||||
"error",
|
||||
);
|
||||
assert.equal(observationSessionVisualState(makeDecoded(null)), "error");
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded(null, { status: "recording" })),
|
||||
"processing",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("ready", { replayable: false })),
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("ready"), { failed: true }),
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("failed"), { pending: true, failed: true }),
|
||||
"error",
|
||||
);
|
||||
assert.equal(observationSessionVisualLabel("ready"), "Готово");
|
||||
assert.equal(observationSessionVisualLabel("processing"), "Обработка");
|
||||
assert.equal(observationSessionVisualLabel("error"), "Ошибка");
|
||||
});
|
||||
|
||||
test("saved-session lamps use green or dim gray only, never warning or danger colors", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation-sessions.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const start = css.indexOf(".observation-session-option i {");
|
||||
const end = css.indexOf(".observation-session-option__state", start);
|
||||
assert.notEqual(start, -1);
|
||||
assert.notEqual(end, -1);
|
||||
const lampRules = css.slice(start, end);
|
||||
assert.match(lampRules, /data-session-visual-state="ready"[\s\S]*--nodedc-success-rgb/);
|
||||
assert.match(lampRules, /data-session-visual-state="processing"[\s\S]*animation:/);
|
||||
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*--nodedc-text-muted/);
|
||||
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*opacity:\s*0\.48/);
|
||||
assert.doesNotMatch(lampRules, /\b(?:warning|danger|yellow|red)\b/i);
|
||||
});
|
||||
|
||||
test("session catalog sorts newest first and uses id as a deterministic tie-breaker", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [
|
||||
session({ id: "same-b", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
|
||||
session({ id: "newest", started_at_utc: "2026-07-17T00:00:00Z", completed_at_utc: null }),
|
||||
session({ id: "same-a", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(catalog.items.map(({ id }) => id), ["newest", "same-a", "same-b"]);
|
||||
});
|
||||
|
||||
test("session catalog rejects raw storage fields, unsafe ids and duplicate ids", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ raw_capture: "sessions/private/mqtt.raw.k1mqtt" })],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ id: "../private" })] }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session(), session()] }),
|
||||
/повторяющийся id/,
|
||||
);
|
||||
});
|
||||
|
||||
test("session catalog rejects invalid temporal, status and duration values", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ started_at_utc: "16 July 2026" })],
|
||||
}),
|
||||
/ISO-датой/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ status: "complete" })] }),
|
||||
/неизвестное состояние/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ duration_seconds: -1 })] }),
|
||||
/неотрицательным числом/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ completed_at_utc: "2026-07-16T19:00:00Z" })],
|
||||
}),
|
||||
/раньше времени начала/,
|
||||
);
|
||||
});
|
||||
|
||||
test("catalog API preserves HTTP detail without accepting a malformed success body", async () => {
|
||||
await assert.rejects(
|
||||
fetchObservationSessionCatalog({
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "storage unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof ObservationSessionApiError &&
|
||||
error.status === 503 && error.message === "storage unavailable",
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
fetchObservationSessionCatalog({
|
||||
fetcher: async () => new Response(JSON.stringify({ items: "not-an-array" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
|
||||
const calls = [];
|
||||
const launch = await replayObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, "/api/v1/observation-sessions/session-20260716T205632Z/replay");
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.equal(launch.kind, "ready");
|
||||
assert.equal(launch.launch.timeline, "session_time");
|
||||
assert.equal(
|
||||
launch.launch.sourceUrl,
|
||||
"/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
|
||||
);
|
||||
await assert.rejects(
|
||||
replayObservationSession("../private", { fetcher: async () => new Response(null) }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("preparation contract is strict, session-scoped and same-origin", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const decoded = decodeObservationSessionPreparation(preparation(), sessionId);
|
||||
assert.equal(decoded.sessionId, sessionId);
|
||||
assert.equal(decoded.state, "queued");
|
||||
assert.equal(decoded.progress, null);
|
||||
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(
|
||||
preparation({ status_url: "https://invalid.test/status" }),
|
||||
sessionId,
|
||||
),
|
||||
/канонический same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(preparation({ debug_path: "/tmp/raw" }), sessionId),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(preparation({ progress: 1.1 }), sessionId),
|
||||
/недопустимое число/,
|
||||
);
|
||||
await assert.rejects(
|
||||
replayObservationSession(sessionId, {
|
||||
fetcher: async () => new Response(JSON.stringify(preparation()), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
/preparation ETag/,
|
||||
);
|
||||
});
|
||||
|
||||
test("202 preparation polls through explicit phases and reveals launch only when ready", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const calls = [];
|
||||
const phases = [];
|
||||
let poll = 0;
|
||||
let clock = 0;
|
||||
let previousViewerMounted = true;
|
||||
const launch = await resolveObservationSessionReplay(sessionId, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 5_000,
|
||||
maximumWaitMs: 10_000,
|
||||
initialPollIntervalMs: 50,
|
||||
maximumPollIntervalMs: 50,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => {
|
||||
assert.equal(previousViewerMounted, true);
|
||||
clock += milliseconds;
|
||||
},
|
||||
onUpdate: (value) => {
|
||||
assert.equal(previousViewerMounted, true);
|
||||
phases.push([value.state, value.progress]);
|
||||
},
|
||||
fetcher: async (input, init) => {
|
||||
calls.push([String(input), init.method, new Headers(init.headers).get("If-Match")]);
|
||||
if (init.method === "POST") {
|
||||
return new Response(JSON.stringify(preparation()), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
poll += 1;
|
||||
if (poll === 1) {
|
||||
return new Response(JSON.stringify(preparation({
|
||||
state: "exporting",
|
||||
progress: 0.5,
|
||||
updated_at_utc: "2026-07-17T10:14:01Z",
|
||||
})), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(replay({ session_id: sessionId })), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
},
|
||||
});
|
||||
// This is the point where App is allowed to unmount the old viewer.
|
||||
previousViewerMounted = false;
|
||||
assert.equal(launch.sessionId, sessionId);
|
||||
assert.deepEqual(phases, [["queued", null], ["exporting", 0.5]]);
|
||||
assert.deepEqual(calls.map((entry) => entry[1]), ["POST", "GET", "GET"]);
|
||||
assert.deepEqual(calls.map((entry) => entry[2]), [null, preparationEtag, preparationEtag]);
|
||||
});
|
||||
|
||||
test("status-ready response must match the preparation ETag before launch is accepted", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "finalizing", progress: 0.95 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let receivedIfMatch;
|
||||
await assert.rejects(
|
||||
fetchObservationSessionPreparation(decoded, {
|
||||
fetcher: async (_input, init) => {
|
||||
receivedIfMatch = new Headers(init.headers).get("If-Match");
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: '"replacement-job"' },
|
||||
});
|
||||
},
|
||||
}),
|
||||
/актуальный preparation ETag/,
|
||||
);
|
||||
assert.equal(receivedIfMatch, preparationEtag);
|
||||
});
|
||||
|
||||
test("preparation cancellation is conditional on the same preparation ETag", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.5 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let request;
|
||||
const result = await cancelObservationSessionPreparation(decoded, {
|
||||
fetcher: async (input, init) => {
|
||||
request = {
|
||||
input: String(input),
|
||||
method: init.method,
|
||||
ifMatch: new Headers(init.headers).get("If-Match"),
|
||||
};
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
assert.equal(result, null);
|
||||
assert.deepEqual(request, {
|
||||
input: decoded.statusUrl,
|
||||
method: "DELETE",
|
||||
ifMatch: preparationEtag,
|
||||
});
|
||||
});
|
||||
|
||||
test("preparation polling fails explicitly when the server heartbeat stalls", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.1 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let clock = 0;
|
||||
await assert.rejects(
|
||||
waitForObservationReplayPreparation(decoded, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 250,
|
||||
maximumWaitMs: 5_000,
|
||||
initialPollIntervalMs: 150,
|
||||
maximumPollIntervalMs: 150,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => { clock += milliseconds; },
|
||||
fetcher: async () => new Response(JSON.stringify(preparation({
|
||||
state: "exporting",
|
||||
progress: 0.1,
|
||||
})), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
}),
|
||||
}),
|
||||
/перестала обновляться/,
|
||||
);
|
||||
});
|
||||
|
||||
test("queued preparation can wait behind the bounded backend worker without false heartbeat failure", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "queued", progress: 0 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let clock = 0;
|
||||
let polls = 0;
|
||||
const launch = await waitForObservationReplayPreparation(decoded, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 250,
|
||||
maximumWaitMs: 5_000,
|
||||
initialPollIntervalMs: 150,
|
||||
maximumPollIntervalMs: 150,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => { clock += milliseconds; },
|
||||
fetcher: async () => {
|
||||
polls += 1;
|
||||
if (polls <= 2) {
|
||||
return new Response(JSON.stringify(preparation({ state: "queued", progress: 0 })), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(clock, 450);
|
||||
assert.equal(launch.sessionId, "session-20260716T205632Z");
|
||||
});
|
||||
|
||||
test("terminal preparation failure is explicit and retryable", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const response = await replayObservationSession(sessionId, {
|
||||
fetcher: async () => new Response(JSON.stringify(preparation({
|
||||
state: "failed",
|
||||
progress: 0.4,
|
||||
cancellable: false,
|
||||
retryable: true,
|
||||
error: "Повреждён индекс исходной записи.",
|
||||
})), {
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
}),
|
||||
});
|
||||
assert.equal(response.kind, "preparing");
|
||||
assert.equal(response.preparation.retryable, true);
|
||||
assert.equal(response.preparation.error, "Повреждён индекс исходной записи.");
|
||||
});
|
||||
|
||||
test("pending preparation survives reload only through its strict canonical contract", () => {
|
||||
const values = new Map();
|
||||
const storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.7 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
storeObservationReplayPreparation(decoded, storage);
|
||||
assert.deepEqual(loadObservationReplayPreparation(storage), decoded);
|
||||
|
||||
const key = storage.key(0);
|
||||
storage.setItem(key, JSON.stringify({ ...preparation(), raw_path: "/private/raw" }));
|
||||
assert.equal(loadObservationReplayPreparation(storage), null);
|
||||
assert.equal(storage.length, 0);
|
||||
});
|
||||
|
||||
test("replay API forwards cancellation and preserves AbortError semantics", async () => {
|
||||
const controller = new AbortController();
|
||||
let receivedSignal;
|
||||
const pending = replayObservationSession("session-20260716T205632Z", {
|
||||
signal: controller.signal,
|
||||
fetcher: async (_input, init) => {
|
||||
receivedSignal = init.signal;
|
||||
return await new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener("abort", () => {
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
assert.equal(receivedSignal, controller.signal);
|
||||
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||||
});
|
||||
|
||||
test("replay coordinator makes rapid saved-session selection latest-request-wins", () => {
|
||||
const coordinator = createObservationReplayCoordinator();
|
||||
const first = coordinator.begin();
|
||||
assert.equal(first.isCurrent(), true);
|
||||
|
||||
const second = coordinator.begin();
|
||||
assert.equal(first.signal.aborted, true);
|
||||
assert.equal(first.isCurrent(), false);
|
||||
assert.equal(first.finish(), false);
|
||||
assert.equal(second.isCurrent(), true);
|
||||
assert.equal(second.finish(), true);
|
||||
assert.equal(second.isCurrent(), false);
|
||||
|
||||
const third = coordinator.begin();
|
||||
coordinator.cancel();
|
||||
assert.equal(third.signal.aborted, true);
|
||||
assert.equal(third.isCurrent(), false);
|
||||
});
|
||||
|
||||
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
|
||||
const hookSource = await readFile(
|
||||
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const selectorSource = await readFile(
|
||||
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(hookSource, /cancelObservationSessionPreparation|method:\s*["']DELETE/);
|
||||
assert.doesNotMatch(selectorSource, /cancelReplay|>\s*Отменить\s*</);
|
||||
});
|
||||
|
||||
test("replay descriptor rejects path leaks, mismatched sessions and non-seekable data", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ source_url: "file:///private/session.rrd" })),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ source_url: "/api/v1/observation-sessions/other/recording.rrd" })),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ seekable: false })),
|
||||
/seekable/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
viewer_source_url: "/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
|
||||
})),
|
||||
/канонически привязан/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
viewer_source_url: `https://foreign.test/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
})),
|
||||
/канонически привязан/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ raw_path: "/private/raw.k1mqtt" })),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ byte_length: Number.MAX_SAFE_INTEGER + 1 })),
|
||||
/недопустимое число/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ playback: { speed: 101, loop: false } })),
|
||||
/недопустимое число/,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest", () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const source = {
|
||||
id: "recorded.camera.5a5a5a5a5a5a5a5a",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
|
||||
manifest_generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
const decoded = decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [source],
|
||||
}));
|
||||
assert.equal(
|
||||
decoded.viewerSourceUrl,
|
||||
`/api/v1/observation-sessions/${sessionId}/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
);
|
||||
assert.equal(decoded.mediaSources.length, 1);
|
||||
assert.equal(decoded.mediaSources[0].id, source.id);
|
||||
assert.equal(decoded.mediaSources[0].manifestUrl, source.manifest_url);
|
||||
assert.equal(
|
||||
decoded.mediaSources[0].manifestGenerationSha256,
|
||||
source.manifest_generation_sha256,
|
||||
);
|
||||
|
||||
const manifest = decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: source.manifest_url.replace("/manifest", "/epochs/1/init.mp4"),
|
||||
init_byte_length: 128,
|
||||
init_sha256: "a".repeat(64),
|
||||
segment_count: 12,
|
||||
segment_url_prefix: source.manifest_url.replace("/manifest", "/epochs/1/segments/"),
|
||||
segments: Array.from({ length: 12 }, (_, index) => ({
|
||||
sequence: index + 1,
|
||||
url: source.manifest_url.replace("/manifest", `/epochs/1/segments/${index + 1}.m4s`),
|
||||
byte_length: 256 + index,
|
||||
sha256: "b".repeat(64),
|
||||
})),
|
||||
}],
|
||||
}, decoded.mediaSources[0]);
|
||||
assert.equal(manifest.epochs[0].segmentCount, 12);
|
||||
assert.equal(manifest.epochs[0].segments.length, 12);
|
||||
assert.equal(manifest.epochs[0].timelineStartSeconds, 0.25);
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
...{
|
||||
schema_version: "missioncore.observation-recorded-media/v1",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [],
|
||||
},
|
||||
}, decoded.mediaSources[0]),
|
||||
/несовместим/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_267,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [],
|
||||
}, decoded.mediaSources[0]),
|
||||
/несовместим|launch descriptor/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded camera contracts reject foreign origins, path escapes and unknown fields", () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const base = {
|
||||
id: "recorded.camera.5a5a5a5a5a5a5a5a",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
|
||||
manifest_generation_sha256: "c".repeat(64),
|
||||
byte_length: 384,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_url: "https://invalid.test/private" }],
|
||||
})),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, path: "/private/archive" }],
|
||||
})),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_generation_sha256: undefined }],
|
||||
})),
|
||||
/immutable generation/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_generation_sha256: "not-a-digest" }],
|
||||
})),
|
||||
/immutable generation/,
|
||||
);
|
||||
const decoded = decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [base],
|
||||
}));
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: base.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 384,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: "file:///private/init.mp4",
|
||||
init_byte_length: 128,
|
||||
init_sha256: "a".repeat(64),
|
||||
segment_count: 1,
|
||||
segment_url_prefix: "file:///private/segments/",
|
||||
segments: [{
|
||||
sequence: 1,
|
||||
url: "file:///private/segments/1.m4s",
|
||||
byte_length: 256,
|
||||
sha256: "b".repeat(64),
|
||||
}],
|
||||
}],
|
||||
}, decoded.mediaSources[0]),
|
||||
/небезопасный API URL/,
|
||||
);
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
|
|
@ -11,6 +13,20 @@ let shouldRestartObservationSource;
|
|||
let consumeCameraLeaseRetry;
|
||||
let resetCameraLeaseRetryBudget;
|
||||
let initialObservationWindowRect;
|
||||
let ObservationTimeline;
|
||||
let shouldCaptureWorkspacePointer;
|
||||
let normalizeTimelineRange;
|
||||
let timelineOffsetSeconds;
|
||||
let formatTimelineDuration;
|
||||
let normalizeAccumulationSeconds;
|
||||
let formatAccumulationDuration;
|
||||
let resolveRerunSourceUrl;
|
||||
let resolveRecordedBlueprintUrl;
|
||||
let fetchRecordedBlueprintRrd;
|
||||
let isRecordedPlaybackFullyBuffered;
|
||||
let recordedObservationSources;
|
||||
let selectRecordedMediaEpoch;
|
||||
let recordedMediaLocalTime;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
|
|
@ -30,9 +46,32 @@ before(async () => {
|
|||
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
|
||||
"/src/components/MseFmp4WebSocketPlayer.tsx",
|
||||
));
|
||||
({ initialObservationWindowRect } = await server.ssrLoadModule(
|
||||
({ initialObservationWindowRect, shouldCaptureWorkspacePointer } = await server.ssrLoadModule(
|
||||
"/src/components/FloatingObservationWindow.tsx",
|
||||
));
|
||||
({
|
||||
ObservationTimeline,
|
||||
normalizeTimelineRange,
|
||||
timelineOffsetSeconds,
|
||||
formatTimelineDuration,
|
||||
normalizeAccumulationSeconds,
|
||||
formatAccumulationDuration,
|
||||
} =
|
||||
await server.ssrLoadModule("/src/components/ObservationTimeline.tsx"));
|
||||
({
|
||||
resolveRerunSourceUrl,
|
||||
resolveRecordedBlueprintUrl,
|
||||
fetchRecordedBlueprintRrd,
|
||||
isRecordedPlaybackFullyBuffered,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedObservationSources } = await server.ssrLoadModule(
|
||||
"/src/core/observation/recordedObservationSources.ts",
|
||||
));
|
||||
({ selectRecordedMediaEpoch, recordedMediaLocalTime } = await server.ssrLoadModule(
|
||||
"/src/components/RecordedFmp4Player.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||
|
|
@ -75,6 +114,251 @@ test("observation camera tiling remains in bounds on a constrained viewport", ()
|
|||
assert.ok(rects[1].y + rects[1].height <= rects[0].y);
|
||||
});
|
||||
|
||||
test("floating observation interaction captures resize and movable header pointers", () => {
|
||||
const target = (matches) => ({ closest: (selector) => matches.includes(selector) });
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__resize"])),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__head"])),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(
|
||||
0,
|
||||
target([".nodedc-workspace-window__head", "button, input, select, textarea, a"]),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(2, target([".nodedc-workspace-window__resize"])),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded observation timeline clamps relative seek time without epoch precision in the UI", () => {
|
||||
const range = normalizeTimelineRange({
|
||||
min: 0,
|
||||
max: 12_500_000_000,
|
||||
});
|
||||
assert.ok(range);
|
||||
assert.equal(timelineOffsetSeconds(range, range.min + 2_250_000_000), 2.25);
|
||||
assert.equal(timelineOffsetSeconds(range, range.min - 1), 0);
|
||||
assert.equal(timelineOffsetSeconds(range, range.max + 1), 12.5);
|
||||
assert.equal(formatTimelineDuration(62.125), "01:02.125");
|
||||
});
|
||||
|
||||
test("recorded observation timeline rejects empty and non-finite ranges", () => {
|
||||
assert.equal(normalizeTimelineRange(null), null);
|
||||
assert.equal(normalizeTimelineRange({ min: 10, max: 10 }), null);
|
||||
assert.equal(normalizeTimelineRange({ min: Number.NaN, max: 10 }), null);
|
||||
});
|
||||
|
||||
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
|
||||
assert.equal(normalizeAccumulationSeconds(-3), 0);
|
||||
assert.equal(normalizeAccumulationSeconds(12.6), 13);
|
||||
assert.equal(normalizeAccumulationSeconds(999), 120);
|
||||
assert.equal(normalizeAccumulationSeconds(Number.NaN), 0);
|
||||
assert.equal(formatAccumulationDuration(0), "Кадр");
|
||||
assert.equal(formatAccumulationDuration(12), "12 с");
|
||||
});
|
||||
|
||||
test("spatial timeline renders synchronized accumulation and playback controls", () => {
|
||||
const markup = renderToStaticMarkup(createElement(ObservationTimeline, {
|
||||
active: true,
|
||||
sourceCount: 2,
|
||||
mode: "recorded",
|
||||
seekable: true,
|
||||
rangeNs: { min: 0, max: 20_000_000_000 },
|
||||
currentNs: 5_000_000_000,
|
||||
accumulationSeconds: 12,
|
||||
onAccumulationChange: () => undefined,
|
||||
onAccumulationCommit: () => undefined,
|
||||
onSeek: () => undefined,
|
||||
}));
|
||||
|
||||
assert.match(markup, /data-accumulation="true"/);
|
||||
assert.match(markup, /Накопление/);
|
||||
assert.match(markup, /aria-label="Окно накопления облака точек"/);
|
||||
assert.match(markup, /aria-valuetext="12 с"/);
|
||||
assert.match(markup, /aria-label="Позиция воспроизведения"/);
|
||||
assert.equal((markup.match(/type="range"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test("Rerun expands only root-relative session recordings onto the current origin", () => {
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl(
|
||||
" /api/v1/observation-sessions/session-1/recording.rrd ",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl("rerun+http://127.0.0.1:9877/proxy", "http://127.0.0.1:5174"),
|
||||
"rerun+http://127.0.0.1:9877/proxy",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl("//different-authority.invalid/session.rrd", "http://127.0.0.1:5174"),
|
||||
"//different-authority.invalid/session.rrd",
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded blueprint endpoint is derived only from canonical same-origin RRD sources", () => {
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"https://outside.invalid/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"/api/v1/observation-sessions/../recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
|
||||
assert.equal(isRecordedPlaybackFullyBuffered(null, 20), false);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_500_000_000 }, 20),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_999_500_000 }, 20),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 1 }, undefined),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 20_000_000_000 }, Number.NaN),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
|
||||
const calls = [];
|
||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||
const result = await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{
|
||||
accumulationSeconds: 24,
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: false,
|
||||
pointSize: 4.5,
|
||||
palette: "custom",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/vnd.rerun.rrd" },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual([...result], [...payload]);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.equal(calls[0].init.credentials, "same-origin");
|
||||
assert.deepEqual(calls[0].body, {
|
||||
application_id: "nodedc_mission_core_recorded",
|
||||
recording_id: "recording-001",
|
||||
accumulation_seconds: 24,
|
||||
show_grid: false,
|
||||
show_points: true,
|
||||
show_trajectory: false,
|
||||
point_size: 4.5,
|
||||
palette: "custom",
|
||||
custom_color: "#35d7c1",
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
fetchRecordedBlueprintRrd(
|
||||
"https://outside.invalid/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{
|
||||
accumulationSeconds: 24,
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: false,
|
||||
pointSize: 4.5,
|
||||
palette: "custom",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{ origin: "http://127.0.0.1:5174", fetcher: async () => new Response(payload) },
|
||||
),
|
||||
/Unsafe recorded blueprint request/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
||||
const sources = recordedObservationSources({
|
||||
kind: "rerun-recording",
|
||||
sessionId: "session-1",
|
||||
sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
viewerSourceUrl: `/api/v1/observation-sessions/session-1/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
mediaType: "application/vnd.rerun.rrd",
|
||||
timeline: "session_time",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
byteLength: 123,
|
||||
sha256: "a".repeat(64),
|
||||
playback: { speed: 1, loop: false },
|
||||
mediaSources: [{
|
||||
id: "recorded.camera.abc123",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifestUrl: "/api/v1/observation-sessions/session-1/media/recorded-video-abc123/manifest",
|
||||
manifestGenerationSha256: "c".repeat(64),
|
||||
byteLength: 1_024,
|
||||
mediaType: "video/mp4",
|
||||
timelineStartSeconds: 0.25,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
}],
|
||||
});
|
||||
assert.deepEqual(sources.map(({ modality }) => modality), ["point-cloud", "video"]);
|
||||
assert.equal(sources[1].delivery.kind, "recorded-fmp4-manifest");
|
||||
assert.equal(sources[1].delivery.manifestGenerationSha256, "c".repeat(64));
|
||||
assert.deepEqual(sources[1].binding, {});
|
||||
assert.equal(sources.some(({ provider }) => provider.pluginId.includes("xgrids")), false);
|
||||
assert.equal(JSON.stringify(sources).includes("192.168"), false);
|
||||
});
|
||||
|
||||
test("recorded camera epoch selection and shared-clock offset are deterministic", () => {
|
||||
const epochs = [
|
||||
{ ordinal: 1, timelineStartSeconds: 0.25, timelineEndSeconds: 9 },
|
||||
{ ordinal: 2, timelineStartSeconds: 10, timelineEndSeconds: 20 },
|
||||
];
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 0), null);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 0.25).ordinal, 1);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 9), epochs[0]);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 9.9), null);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 10).ordinal, 2);
|
||||
assert.equal(recordedMediaLocalTime(10, 12.5), 2.5);
|
||||
assert.equal(recordedMediaLocalTime(10, 8), 0);
|
||||
assert.equal(recordedMediaLocalTime(10, 30, 4), 4);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchVerifiedRecordedMediaArchive;
|
||||
let recordedMediaPresentationState;
|
||||
let recordedMediaSeekableCoverage;
|
||||
let appendRecordedMediaBuffer;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchVerifiedRecordedMediaArchive,
|
||||
recordedMediaPresentationState,
|
||||
recordedMediaSeekableCoverage,
|
||||
appendRecordedMediaBuffer,
|
||||
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function digest(payload) {
|
||||
return createHash("sha256").update(payload).digest("hex");
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const manifestUrl = "/api/v1/observation-sessions/session-1/media/camera-1/manifest";
|
||||
const init = Buffer.from("canonical-init");
|
||||
const first = Buffer.from("canonical-first-fragment");
|
||||
const second = Buffer.from("canonical-second-fragment");
|
||||
const generation = "a".repeat(64);
|
||||
const segmentPrefix = manifestUrl.replace("/manifest", "/epochs/1/segments/");
|
||||
const manifest = {
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: "recorded.camera.camera-1",
|
||||
generation_sha256: generation,
|
||||
byte_length: init.byteLength + first.byteLength + second.byteLength,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: manifestUrl.replace("/manifest", "/epochs/1/init.mp4"),
|
||||
init_byte_length: init.byteLength,
|
||||
init_sha256: digest(init),
|
||||
segment_count: 2,
|
||||
segment_url_prefix: segmentPrefix,
|
||||
segments: [first, second].map((payload, index) => ({
|
||||
sequence: index + 1,
|
||||
url: `${segmentPrefix}${index + 1}.m4s`,
|
||||
byte_length: payload.byteLength,
|
||||
sha256: digest(payload),
|
||||
})),
|
||||
}],
|
||||
};
|
||||
const source = {
|
||||
id: manifest.source_id,
|
||||
label: "Записанная камера",
|
||||
modality: "video",
|
||||
manifestUrl,
|
||||
manifestGenerationSha256: generation,
|
||||
byteLength: manifest.byte_length,
|
||||
mediaType: "video/mp4",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
return { source, manifest, generation, init, first, second };
|
||||
}
|
||||
|
||||
function jsonResponse(payload, generation) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
ETag: `"sha256:${generation}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mediaResponse(payload, sha = digest(payload), length = payload.byteLength) {
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Length": String(length),
|
||||
ETag: `"sha256:${sha}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test("recorded camera archive stays pending until every canonical byte is verified", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const finalSegment = deferred();
|
||||
const requested = [];
|
||||
let manifestRequests = 0;
|
||||
const fetcher = async (input, request = {}) => {
|
||||
const url = String(input);
|
||||
requested.push(url);
|
||||
const headers = new Headers(request.headers);
|
||||
if (url === source.manifestUrl) {
|
||||
manifestRequests += 1;
|
||||
assert.equal(headers.get("If-Match"), `"sha256:${generation}"`);
|
||||
return jsonResponse(manifest, generation);
|
||||
}
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) {
|
||||
assert.equal(headers.get("If-Match"), `"sha256:${epoch.init_sha256}"`);
|
||||
return mediaResponse(init);
|
||||
}
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) return finalSegment.promise;
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
const archivePromise = fetchVerifiedRecordedMediaArchive(source, { fetcher })
|
||||
.then((archive) => {
|
||||
settled = true;
|
||||
return archive;
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
assert.equal(settled, false, "init and a partial segment prefix must not publish the camera");
|
||||
assert.deepEqual(requested.slice(0, 4), [
|
||||
source.manifestUrl,
|
||||
manifest.epochs[0].init_url,
|
||||
manifest.epochs[0].segments[0].url,
|
||||
manifest.epochs[0].segments[1].url,
|
||||
]);
|
||||
|
||||
finalSegment.resolve(mediaResponse(second));
|
||||
const archive = await archivePromise;
|
||||
assert.equal(manifestRequests, 2, "the immutable generation is revalidated after transfer");
|
||||
assert.equal(archive.byteLength, init.byteLength + first.byteLength + second.byteLength);
|
||||
assert.equal(archive.epochs[0].segments.length, 2);
|
||||
});
|
||||
|
||||
test("first camera manifest request is launch-generation bound and rejects replacement", async () => {
|
||||
const { source, manifest, generation } = fixture();
|
||||
const replacementGeneration = "d".repeat(64);
|
||||
let requests = 0;
|
||||
const fetcher = async (input, request = {}) => {
|
||||
requests += 1;
|
||||
assert.equal(String(input), source.manifestUrl);
|
||||
assert.equal(
|
||||
new Headers(request.headers).get("If-Match"),
|
||||
`"sha256:${generation}"`,
|
||||
);
|
||||
return jsonResponse(
|
||||
{ ...manifest, generation_sha256: replacementGeneration },
|
||||
replacementGeneration,
|
||||
);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/несовместим|заменён/,
|
||||
);
|
||||
assert.equal(requests, 1);
|
||||
});
|
||||
|
||||
test("recorded camera archive fails closed on truncation despite plausible headers", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const fetcher = async (input) => {
|
||||
const url = String(input);
|
||||
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) return mediaResponse(init);
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) {
|
||||
return mediaResponse(second.subarray(0, second.byteLength - 1), digest(second), second.byteLength);
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/усечён/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded camera archive fails closed when bytes are replaced under an old ETag", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const replacement = Buffer.from(second.map((value) => value ^ 0xff));
|
||||
assert.equal(replacement.byteLength, second.byteLength);
|
||||
const fetcher = async (input) => {
|
||||
const url = String(input);
|
||||
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) return mediaResponse(init);
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) {
|
||||
return mediaResponse(replacement, digest(second), second.byteLength);
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/SHA-256/,
|
||||
);
|
||||
});
|
||||
|
||||
test("camera presentation gate opens only for the completely appended selected epoch", () => {
|
||||
assert.equal(recordedMediaPresentationState("loading", null, "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", null, "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g2", "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false), "ready");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", null, true), "waiting");
|
||||
assert.equal(recordedMediaPresentationState("error", "g1", "g1", false), "error");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "loading"), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", null, true, "loading"), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "error"), "error");
|
||||
});
|
||||
|
||||
test("decoded duration and seekable range cover the complete declared epoch", () => {
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(19, 19, 20), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(18.99, 20, 20), false);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 18.99, 20), false);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
|
||||
});
|
||||
|
||||
test("aborting SourceBuffer append removes every temporary listener", async () => {
|
||||
const listeners = new Map();
|
||||
const sourceBuffer = {
|
||||
addEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? new Set();
|
||||
bucket.add(listener);
|
||||
listeners.set(type, bucket);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
appendBuffer() {},
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const pending = appendRecordedMediaBuffer(
|
||||
sourceBuffer,
|
||||
new ArrayBuffer(8),
|
||||
abort.signal,
|
||||
);
|
||||
assert.equal(listeners.get("updateend")?.size, 1);
|
||||
assert.equal(listeners.get("error")?.size, 1);
|
||||
abort.abort();
|
||||
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||||
assert.equal(listeners.get("updateend")?.size, 0);
|
||||
assert.equal(listeners.get("error")?.size, 0);
|
||||
});
|
||||
|
||||
test("verified camera archives remain immutable for safe player remount", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(source, /\.init\s*=\s*new ArrayBuffer/);
|
||||
assert.doesNotMatch(source, /\.segments\.length\s*=\s*0/);
|
||||
});
|
||||
|
||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.recorded-media-player:not\(\[data-state="ready"\]\) \.observation-media__asset\s*\{[^}]*visibility:\s*hidden/s,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.recorded-media-player__notice\s*\{[^}]*inset:\s*0;[^}]*background:\s*#070809/s,
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let admission;
|
||||
let rerunPresentationStatus;
|
||||
let recordedMediaPresentationState;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
admission = await server.ssrLoadModule(
|
||||
"/src/core/observation/recordedSessionAdmission.ts",
|
||||
);
|
||||
({ rerunPresentationStatus } = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedMediaPresentationState } = await server.ssrLoadModule(
|
||||
"/src/components/RecordedFmp4Player.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function camera(phase, byteLength = 1_024) {
|
||||
return { phase, byteLength, message: null };
|
||||
}
|
||||
|
||||
test("recorded session admits RRD and every declared camera as one atomic generation", () => {
|
||||
const ids = ["camera.left", "camera.right"];
|
||||
const oneCamera = {
|
||||
"camera.left": camera("ready"),
|
||||
"camera.right": camera("pending"),
|
||||
};
|
||||
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
|
||||
assert.equal(partialGate, "loading");
|
||||
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
|
||||
"loading",
|
||||
);
|
||||
|
||||
const completeGate = admission.recordedSessionAdmissionPhase("ready", ids, {
|
||||
...oneCamera,
|
||||
"camera.right": camera("ready"),
|
||||
});
|
||||
assert.equal(completeGate, "ready");
|
||||
assert.equal(rerunPresentationStatus("ready", completeGate, true), "ready");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, completeGate),
|
||||
"ready",
|
||||
);
|
||||
});
|
||||
|
||||
test("any RRD or camera failure closes the complete recorded session", () => {
|
||||
const ids = ["camera.left", "camera.right"];
|
||||
const cameras = {
|
||||
"camera.left": camera("ready"),
|
||||
"camera.right": camera("error"),
|
||||
};
|
||||
assert.equal(admission.recordedSessionAdmissionPhase("ready", ids, cameras), "error");
|
||||
assert.equal(admission.recordedSessionAdmissionPhase("error", ids, {
|
||||
...cameras,
|
||||
"camera.right": camera("ready"),
|
||||
}), "error");
|
||||
assert.equal(rerunPresentationStatus("ready", "error", true), "error");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
test("camera admission enforces independent 16/128/512 MiB limits before fetching", () => {
|
||||
const {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
MAX_RECORDED_SESSION_CAMERA_BYTES,
|
||||
recordedCameraDescriptorPreflight,
|
||||
} = admission;
|
||||
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
|
||||
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 128 * 1024 * 1024);
|
||||
assert.equal(MAX_RECORDED_SESSION_CAMERA_BYTES, 512 * 1024 * 1024);
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
})),
|
||||
), "ready");
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 17 }, (_, index) => ({ id: `camera.${index}`, byteLength: 1 })),
|
||||
), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.large", byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES + 1 },
|
||||
]), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: 110 * 1024 * 1024,
|
||||
})),
|
||||
), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.duplicate", byteLength: 1 },
|
||||
{ id: "camera.duplicate", byteLength: 1 },
|
||||
]), "error");
|
||||
});
|
||||
|
||||
test("camera preparation remains single-flight and retains the loading permit", () => {
|
||||
const ids = ["camera.first", "camera.preferred"];
|
||||
const cameras = {
|
||||
"camera.first": camera("loading"),
|
||||
"camera.preferred": camera("pending"),
|
||||
};
|
||||
assert.deepEqual(
|
||||
admission.nextRecordedCameraPreparationIds(
|
||||
ids,
|
||||
cameras,
|
||||
new Set(["camera.preferred"]),
|
||||
),
|
||||
["camera.first"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
admission.nextRecordedCameraPreparationIds(ids, {
|
||||
...cameras,
|
||||
"camera.first": camera("ready"),
|
||||
}),
|
||||
["camera.preferred"],
|
||||
);
|
||||
});
|
||||
|
||||
test("new render workers close readiness and stale callbacks cannot reopen it", () => {
|
||||
const ready = { ...camera("ready"), workerGeneration: 1 };
|
||||
const remounted = admission.mergeRecordedCameraAdmission(ready, {
|
||||
...camera("loading"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(remounted.phase, "loading");
|
||||
assert.equal(remounted.workerGeneration, 2);
|
||||
assert.equal(admission.mergeRecordedCameraAdmission(remounted, {
|
||||
...camera("ready"),
|
||||
workerGeneration: 1,
|
||||
}), remounted);
|
||||
const admitted = admission.mergeRecordedCameraAdmission(remounted, {
|
||||
...camera("ready"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(admitted.phase, "ready");
|
||||
const failed = admission.mergeRecordedCameraAdmission(admitted, {
|
||||
...camera("error"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(failed.phase, "error");
|
||||
assert.equal(admission.mergeRecordedCameraAdmission(failed, {
|
||||
...camera("loading"),
|
||||
workerGeneration: 3,
|
||||
}), failed);
|
||||
});
|
||||
|
||||
test("recorded player is not mounted for a camera without a scheduler permit", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/ObservationSources.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const placeholder = source.indexOf("if (!prepareRecorded)");
|
||||
const player = source.indexOf("<RecordedFmp4Player", placeholder);
|
||||
assert.ok(placeholder >= 0 && player > placeholder);
|
||||
assert.match(source.slice(placeholder, player), /return\s*\(/);
|
||||
});
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let createRecordedOpenWatchdog;
|
||||
let recordedOpenWatchdogTimeoutMs;
|
||||
let rerunViewerInitialSource;
|
||||
let resolveRecordedViewerSourceUrl;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
createRecordedOpenWatchdog,
|
||||
recordedOpenWatchdogTimeoutMs,
|
||||
rerunViewerInitialSource,
|
||||
resolveRecordedViewerSourceUrl,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
const sourceUrl = "/api/v1/observation-sessions/session-atomic/recording.rrd";
|
||||
const sha256 = "a".repeat(64);
|
||||
const viewerSourceUrl = `${sourceUrl}?generation=${sha256}`;
|
||||
|
||||
test("only the canonical digest-bound generation URL reaches the native Rerun receiver", () => {
|
||||
const descriptor = {
|
||||
sourceUrl,
|
||||
viewerSourceUrl,
|
||||
byteLength: 246_331_680,
|
||||
sha256,
|
||||
};
|
||||
const resolved = resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test");
|
||||
assert.equal(resolved, `http://mission-core.test${viewerSourceUrl}`);
|
||||
assert.equal(rerunViewerInitialSource(resolved), resolved);
|
||||
|
||||
for (const unsafeViewerSourceUrl of [
|
||||
sourceUrl,
|
||||
`${sourceUrl}?generation=${"b".repeat(64)}`,
|
||||
`${viewerSourceUrl}&extra=true`,
|
||||
`https://foreign.test${viewerSourceUrl}`,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => resolveRecordedViewerSourceUrl({
|
||||
...descriptor,
|
||||
viewerSourceUrl: unsafeViewerSourceUrl,
|
||||
}, "http://mission-core.test"),
|
||||
/Unsafe recorded RRD viewer descriptor/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("recorded admission watchdog is size-aware and exits an incomplete load", () => {
|
||||
const smallDelay = recordedOpenWatchdogTimeoutMs(4);
|
||||
const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680);
|
||||
const largeDelay = recordedOpenWatchdogTimeoutMs(805_687_122);
|
||||
assert.ok(smallDelay >= 120_000);
|
||||
assert.ok(currentDelay >= smallDelay);
|
||||
assert.ok(largeDelay > currentDelay);
|
||||
assert.ok(largeDelay <= 1_800_000);
|
||||
assert.ok(largeDelay > 12_000);
|
||||
|
||||
let scheduledCallback;
|
||||
let scheduledDelay;
|
||||
let timeoutCount = 0;
|
||||
const cancelled = [];
|
||||
const watchdog = createRecordedOpenWatchdog({
|
||||
byteLength: 246_331_680,
|
||||
schedule(callback, timeoutMs) {
|
||||
scheduledCallback = callback;
|
||||
scheduledDelay = timeoutMs;
|
||||
return 17;
|
||||
},
|
||||
cancel(handle) {
|
||||
cancelled.push(handle);
|
||||
},
|
||||
onTimeout() {
|
||||
timeoutCount += 1;
|
||||
},
|
||||
});
|
||||
watchdog.arm();
|
||||
assert.equal(watchdog.pending(), true);
|
||||
assert.equal(scheduledDelay, currentDelay);
|
||||
scheduledCallback();
|
||||
assert.equal(timeoutCount, 1);
|
||||
assert.equal(watchdog.pending(), false);
|
||||
assert.deepEqual(cancelled, []);
|
||||
});
|
||||
|
||||
test("complete recorded admission clears its watchdog", () => {
|
||||
let timeoutCount = 0;
|
||||
const cancelled = [];
|
||||
const watchdog = createRecordedOpenWatchdog({
|
||||
byteLength: 805_687_122,
|
||||
schedule() {
|
||||
return 23;
|
||||
},
|
||||
cancel(handle) {
|
||||
cancelled.push(handle);
|
||||
},
|
||||
onTimeout() {
|
||||
timeoutCount += 1;
|
||||
},
|
||||
});
|
||||
watchdog.arm();
|
||||
watchdog.clear();
|
||||
assert.equal(watchdog.pending(), false);
|
||||
assert.equal(timeoutCount, 0);
|
||||
assert.deepEqual(cancelled, [23]);
|
||||
});
|
||||
|
||||
test("recorded RRD bytes are never split across LogChannel.send_rrd calls", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RerunViewport.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(source, /streamVerifiedRecordedRrd/);
|
||||
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
||||
assert.doesNotMatch(source, /recordedChannel/);
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.match(
|
||||
source,
|
||||
/recordingOpened = true;[\s\S]*if \(!isRecordedSource\) clearLiveRecordingOpenTimer\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/viewerStartResolved = true;\s*if \(isRecordedSource && !recordedSceneAdmitted\) recordedOpenWatchdog\?\.arm\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
||||
const css = await readFile(new URL("../src/styles/spatial.css", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
css,
|
||||
/\.rerun-viewport:is\(\[data-status="loading"\], \[data-status="error"\]\)\s+\.rerun-viewport__canvas\s*\{[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;/s,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/data-status="loading"[^}]*\.rerun-viewport__canvas\s*>\s*div/s,
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let attemptRecordedAutoplay;
|
||||
let createLatestAnimationFrameEmitter;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ attemptRecordedAutoplay, createLatestAnimationFrameEmitter } =
|
||||
await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("recorded autoplay reports success only after seek and play both succeed", () => {
|
||||
let seekAttempts = 0;
|
||||
let playAttempts = 0;
|
||||
const seekToStart = () => {
|
||||
seekAttempts += 1;
|
||||
};
|
||||
const startPlaying = () => {
|
||||
playAttempts += 1;
|
||||
if (playAttempts === 1) throw new Error("receiver is not ready yet");
|
||||
};
|
||||
|
||||
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), false);
|
||||
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), true);
|
||||
assert.equal(seekAttempts, 2);
|
||||
assert.equal(playAttempts, 2);
|
||||
});
|
||||
|
||||
test("recorded autoplay does not try play when the seek itself fails", () => {
|
||||
let playAttempts = 0;
|
||||
|
||||
assert.equal(attemptRecordedAutoplay(
|
||||
() => {
|
||||
throw new Error("timeline is not ready yet");
|
||||
},
|
||||
() => {
|
||||
playAttempts += 1;
|
||||
},
|
||||
), false);
|
||||
assert.equal(playAttempts, 0);
|
||||
});
|
||||
|
||||
test("time updates coalesce to the latest value once per animation frame", () => {
|
||||
let nextHandle = 1;
|
||||
const frames = new Map();
|
||||
const cancelled = [];
|
||||
const emitted = [];
|
||||
const emitter = createLatestAnimationFrameEmitter({
|
||||
emit(value) {
|
||||
emitted.push(value);
|
||||
},
|
||||
requestFrame(callback) {
|
||||
const handle = nextHandle;
|
||||
nextHandle += 1;
|
||||
frames.set(handle, callback);
|
||||
return handle;
|
||||
},
|
||||
cancelFrame(handle) {
|
||||
cancelled.push(handle);
|
||||
frames.delete(handle);
|
||||
},
|
||||
});
|
||||
|
||||
emitter.push(1);
|
||||
emitter.push(2);
|
||||
emitter.push(3);
|
||||
assert.equal(frames.size, 1);
|
||||
assert.deepEqual(emitted, []);
|
||||
|
||||
const firstFrame = frames.get(1);
|
||||
frames.delete(1);
|
||||
firstFrame(0);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
|
||||
emitter.push(4);
|
||||
assert.equal(frames.size, 1);
|
||||
emitter.cancel();
|
||||
assert.deepEqual(cancelled, [2]);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
|
||||
emitter.push(5);
|
||||
assert.equal(frames.size, 0);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
});
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let canPublishRecordedPlaybackController;
|
||||
let createRecordedAutoplayGate;
|
||||
let isRecordedPlaybackReady;
|
||||
let isRecordedPlaybackPresentationReady;
|
||||
let isUsableRecordedPlaybackRange;
|
||||
let recordedPlaybackBufferState;
|
||||
let recordedPlaybackRangeWhenReady;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
canPublishRecordedPlaybackController,
|
||||
createRecordedAutoplayGate,
|
||||
isRecordedPlaybackReady,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
isUsableRecordedPlaybackRange,
|
||||
recordedPlaybackBufferState,
|
||||
recordedPlaybackRangeWhenReady,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("a first frame reports buffer telemetry but is not ready for presentation", () => {
|
||||
assert.equal(isUsableRecordedPlaybackRange(null), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: 0, max: 0 }), true);
|
||||
|
||||
const firstFrame = recordedPlaybackBufferState({ min: 0, max: 0 }, 20);
|
||||
assert.deepEqual(firstFrame, {
|
||||
bufferedEndNs: 0,
|
||||
expectedEndNs: 20_000_000_000,
|
||||
bufferProgress: 0,
|
||||
fullyBuffered: false,
|
||||
});
|
||||
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false);
|
||||
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
|
||||
});
|
||||
|
||||
test("host timeline remains unmounted until the verified recording is fully ready", () => {
|
||||
const partial = {
|
||||
recordingId: "partial",
|
||||
timeline: "session_time",
|
||||
rangeNs: null,
|
||||
currentNs: 0,
|
||||
playing: false,
|
||||
bufferedEndNs: 5,
|
||||
expectedEndNs: 10,
|
||||
bufferProgress: 0.5,
|
||||
fullyBuffered: false,
|
||||
};
|
||||
assert.equal(isRecordedPlaybackPresentationReady("loading", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("ready", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("error", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("ready", {
|
||||
...partial,
|
||||
rangeNs: { min: 0, max: 10 },
|
||||
bufferedEndNs: 10,
|
||||
bufferProgress: 1,
|
||||
fullyBuffered: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("buffer progress grows independently and preserves the full-buffer tolerance", () => {
|
||||
assert.deepEqual(recordedPlaybackBufferState({ min: 0, max: 5_000_000_000 }, 20), {
|
||||
bufferedEndNs: 5_000_000_000,
|
||||
expectedEndNs: 20_000_000_000,
|
||||
bufferProgress: 0.25,
|
||||
fullyBuffered: false,
|
||||
});
|
||||
|
||||
const complete = recordedPlaybackBufferState({ min: 0, max: 19_999_500_000 }, 20);
|
||||
assert.equal(complete.bufferProgress, 0.999975);
|
||||
assert.equal(complete.fullyBuffered, true);
|
||||
assert.equal(isRecordedPlaybackReady(false, true, complete), false);
|
||||
assert.equal(isRecordedPlaybackReady(true, false, complete), false);
|
||||
assert.equal(isRecordedPlaybackReady(true, true, complete), true);
|
||||
assert.equal(
|
||||
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, false),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, true),
|
||||
{ min: 0, max: 19_999_500_000 },
|
||||
);
|
||||
|
||||
const wrongDeclaredStart = recordedPlaybackBufferState(
|
||||
{ min: 5_000_000_000, max: 20_000_000_000 },
|
||||
20,
|
||||
0,
|
||||
);
|
||||
assert.equal(wrongDeclaredStart.bufferProgress, 1);
|
||||
assert.equal(wrongDeclaredStart.fullyBuffered, false);
|
||||
assert.equal(isRecordedPlaybackReady(true, true, wrongDeclaredStart), false);
|
||||
});
|
||||
|
||||
test("recorded autoplay waits for the full range and then runs exactly once", () => {
|
||||
const gate = createRecordedAutoplayGate();
|
||||
const seeks = [];
|
||||
let plays = 0;
|
||||
const seek = (value) => seeks.push(value);
|
||||
const play = () => {
|
||||
plays += 1;
|
||||
};
|
||||
|
||||
assert.equal(gate.attempt(false, true, true, { min: 0, max: 0 }, seek, play), false);
|
||||
assert.equal(gate.attempt(true, false, true, { min: 0, max: 500_000_000 }, seek, play), false);
|
||||
assert.equal(gate.attempt(true, true, false, { min: 0, max: 20_000_000_000 }, seek, play), false);
|
||||
assert.deepEqual(seeks, []);
|
||||
assert.equal(plays, 0);
|
||||
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), true);
|
||||
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), false);
|
||||
assert.deepEqual(seeks, [0]);
|
||||
assert.equal(plays, 1);
|
||||
assert.equal(gate.attempted(), true);
|
||||
});
|
||||
|
||||
test("a failed vendor autoplay attempt is consumed instead of rewinding later", () => {
|
||||
const gate = createRecordedAutoplayGate();
|
||||
let seeks = 0;
|
||||
let plays = 0;
|
||||
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
{ min: 0, max: 1 },
|
||||
() => {
|
||||
seeks += 1;
|
||||
},
|
||||
() => {
|
||||
plays += 1;
|
||||
throw new Error("receiver raced its first frame");
|
||||
},
|
||||
), false);
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
{ min: 0, max: 2 },
|
||||
() => {
|
||||
seeks += 1;
|
||||
},
|
||||
() => {
|
||||
plays += 1;
|
||||
},
|
||||
), false);
|
||||
assert.equal(seeks, 1);
|
||||
assert.equal(plays, 1);
|
||||
});
|
||||
|
||||
test("recorded playback controller stays unpublished until the aggregate gate is ready", () => {
|
||||
assert.equal(canPublishRecordedPlaybackController(false, "ready"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "loading"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "error"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "ready"), true);
|
||||
});
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let createLatestAsyncCommitter;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ createLatestAsyncCommitter } = await server.ssrLoadModule(
|
||||
"/src/core/runtime/latestAsyncCommitter.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
async function eventually(predicate, timeoutMs = 1_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for async commit queue");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
test("viewer settings commits are serialized and intermediate slider values collapse", async () => {
|
||||
const calls = [];
|
||||
const resolvers = [];
|
||||
const settled = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit(value) {
|
||||
calls.push(value);
|
||||
return new Promise((resolve) => resolvers.push(resolve));
|
||||
},
|
||||
onSettled(result) {
|
||||
settled.push(result);
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue({ accumulationSeconds: 1 });
|
||||
committer.enqueue({ accumulationSeconds: 2 });
|
||||
committer.enqueue({ accumulationSeconds: 12 });
|
||||
|
||||
assert.deepEqual(calls, [{ accumulationSeconds: 1 }]);
|
||||
assert.equal(committer.isBusy(), true);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => calls.length === 2);
|
||||
|
||||
assert.deepEqual(calls[1], { accumulationSeconds: 12 });
|
||||
assert.equal(settled[0].superseded, true);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => !committer.isBusy());
|
||||
|
||||
assert.deepEqual(settled.map(({ value, applied, superseded }) => ({
|
||||
value,
|
||||
applied,
|
||||
superseded,
|
||||
})), [
|
||||
{ value: { accumulationSeconds: 1 }, applied: true, superseded: true },
|
||||
{ value: { accumulationSeconds: 12 }, applied: true, superseded: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test("viewer settings commit failures settle as rejected without wedging the queue", async () => {
|
||||
const settled = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
async commit(value) {
|
||||
if (value === "broken") throw new Error("offline");
|
||||
return true;
|
||||
},
|
||||
onSettled(result) {
|
||||
settled.push(result);
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue("broken");
|
||||
await eventually(() => settled.length === 1);
|
||||
committer.enqueue("healthy");
|
||||
await eventually(() => settled.length === 2);
|
||||
|
||||
assert.deepEqual(settled.map(({ value, applied }) => ({ value, applied })), [
|
||||
{ value: "broken", applied: false },
|
||||
{ value: "healthy", applied: true },
|
||||
]);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("waitForIdle resolves only after the running commit and latest queued value settle", async () => {
|
||||
const calls = [];
|
||||
const resolvers = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit(value) {
|
||||
calls.push(value);
|
||||
return new Promise((resolve) => resolvers.push(resolve));
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue("initial");
|
||||
committer.enqueue("latest");
|
||||
let idle = false;
|
||||
const idlePromise = committer.waitForIdle().then(() => {
|
||||
idle = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(idle, false);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => calls.length === 2);
|
||||
assert.deepEqual(calls, ["initial", "latest"]);
|
||||
assert.equal(idle, false);
|
||||
|
||||
resolvers.shift()(true);
|
||||
await idlePromise;
|
||||
assert.equal(idle, true);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("waitForIdle observes work enqueued by onSettled and resolves immediately when idle", async () => {
|
||||
const calls = [];
|
||||
let committer;
|
||||
committer = createLatestAsyncCommitter({
|
||||
async commit(value) {
|
||||
calls.push(value);
|
||||
return true;
|
||||
},
|
||||
onSettled({ value }) {
|
||||
if (value === "first") committer.enqueue("follow-up");
|
||||
},
|
||||
});
|
||||
|
||||
await committer.waitForIdle();
|
||||
committer.enqueue("first");
|
||||
await committer.waitForIdle();
|
||||
|
||||
assert.deepEqual(calls, ["first", "follow-up"]);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("layout serialization can flush a staged setting and read the confirmed latest value", async () => {
|
||||
let staged = { pointSize: 2 };
|
||||
let confirmed = { pointSize: 1 };
|
||||
let resolveCommit;
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit() {
|
||||
return new Promise((resolve) => {
|
||||
resolveCommit = resolve;
|
||||
});
|
||||
},
|
||||
onSettled({ value, applied }) {
|
||||
if (applied) confirmed = value;
|
||||
},
|
||||
});
|
||||
|
||||
const saveLayout = async () => {
|
||||
committer.enqueue(staged);
|
||||
await committer.waitForIdle();
|
||||
return { sceneSettings: confirmed };
|
||||
};
|
||||
|
||||
const savePromise = saveLayout();
|
||||
staged = { pointSize: 9 };
|
||||
let saved = false;
|
||||
void savePromise.then(() => {
|
||||
saved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(saved, false);
|
||||
|
||||
resolveCommit(true);
|
||||
const layout = await savePromise;
|
||||
assert.deepEqual(layout, { sceneSettings: { pointSize: 2 } });
|
||||
});
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let decodeObservationWorkspaceLayoutProfile;
|
||||
let encodeObservationWorkspaceLayoutProfile;
|
||||
let fetchObservationWorkspaceLayoutProfile;
|
||||
let normalizeObservationWindowRect;
|
||||
let projectObservationLayoutSnapshot;
|
||||
let saveObservationWorkspaceLayoutProfile;
|
||||
let WorkspaceLayoutApiError;
|
||||
let WorkspaceLayoutContractError;
|
||||
let observationPresentationSourceAfterLayoutApply;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
decodeObservationWorkspaceLayoutProfile,
|
||||
encodeObservationWorkspaceLayoutProfile,
|
||||
fetchObservationWorkspaceLayoutProfile,
|
||||
normalizeObservationWindowRect,
|
||||
projectObservationLayoutSnapshot,
|
||||
saveObservationWorkspaceLayoutProfile,
|
||||
WorkspaceLayoutApiError,
|
||||
WorkspaceLayoutContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
|
||||
({ observationPresentationSourceAfterLayoutApply } = await server.ssrLoadModule(
|
||||
"/src/core/observation/useObservationLayout.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function wireProfile(overrides = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
revision: 7,
|
||||
workspace_id: "observation.spatial",
|
||||
scene_settings: {
|
||||
projection: "3d",
|
||||
point_size: 2.5,
|
||||
color_mode: "intensity",
|
||||
palette: "turbo",
|
||||
custom_color: "#35d7c1",
|
||||
accumulation_seconds: 12,
|
||||
show_points: true,
|
||||
show_trajectory: true,
|
||||
show_grid: true,
|
||||
show_labels: false,
|
||||
show_camera_frustums: true,
|
||||
},
|
||||
tool_windows: {
|
||||
sources_open: true,
|
||||
display_open: false,
|
||||
layers_open: true,
|
||||
order: ["sources", "layers", "display"],
|
||||
},
|
||||
visible_source_ids: ["spatial.point-cloud.live", "camera.left"],
|
||||
active_floating_source_id: "camera.left",
|
||||
window_rects: {
|
||||
"camera.left": { x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
|
||||
"camera.right": { x: 0.55, y: 0.1, width: 0.4, height: 0.4 },
|
||||
},
|
||||
viewport_size: { width: 1_000, height: 500 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("workspace layout performs a lossless strict wire/camel/wire round trip", () => {
|
||||
const wire = wireProfile();
|
||||
const profile = decodeObservationWorkspaceLayoutProfile(wire);
|
||||
|
||||
assert.deepEqual(profile.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
|
||||
assert.deepEqual(profile.toolWindows.order, ["sources", "layers", "display"]);
|
||||
assert.equal(profile.sceneSettings.pointSize, 2.5);
|
||||
assert.deepEqual(encodeObservationWorkspaceLayoutProfile(profile), wire);
|
||||
});
|
||||
|
||||
test("workspace layout rejects unknown fields, transient transport data and invalid schema values", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile({
|
||||
...wireProfile(),
|
||||
source_url: "ws://192.168.68.52:9877",
|
||||
}),
|
||||
WorkspaceLayoutContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 2 })),
|
||||
/Неподдерживаемая версия/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ revision: 1.5 })),
|
||||
/revision/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
visible_source_ids: ["../camera"],
|
||||
active_floating_source_id: null,
|
||||
})),
|
||||
/стабильный идентификатор/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
window_rects: { "camera.left": { x: 0.8, y: 0, width: 0.4, height: 0.5 } },
|
||||
})),
|
||||
/выходит за нормализованные границы/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
tool_windows: {
|
||||
sources_open: true,
|
||||
display_open: true,
|
||||
layers_open: true,
|
||||
order: ["sources", "sources", "layers"],
|
||||
},
|
||||
})),
|
||||
/перестановкой/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
scene_settings: { ...wireProfile().scene_settings, point_size: Number.POSITIVE_INFINITY },
|
||||
})),
|
||||
/point_size/,
|
||||
);
|
||||
});
|
||||
|
||||
test("restored desired layout survives an empty catalog and reveals only known stable sources later", () => {
|
||||
const profile = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
const snapshot = {
|
||||
visibleSourceIds: profile.visibleSourceIds,
|
||||
activeFloatingSourceId: profile.activeFloatingSourceId,
|
||||
windowRects: profile.windowRects,
|
||||
viewportSize: profile.viewportSize,
|
||||
};
|
||||
|
||||
const empty = projectObservationLayoutSnapshot(snapshot, new Set(), { width: 500, height: 1_000 });
|
||||
assert.deepEqual(empty, {
|
||||
visibleSourceIds: [],
|
||||
activeFloatingSourceId: null,
|
||||
windowRects: {},
|
||||
});
|
||||
assert.deepEqual(snapshot.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
|
||||
|
||||
const cameraAppeared = projectObservationLayoutSnapshot(
|
||||
snapshot,
|
||||
new Set(["camera.left"]),
|
||||
{ width: 500, height: 1_000 },
|
||||
);
|
||||
assert.deepEqual(cameraAppeared.visibleSourceIds, ["camera.left"]);
|
||||
assert.equal(cameraAppeared.activeFloatingSourceId, "camera.left");
|
||||
assert.deepEqual(cameraAppeared.windowRects["camera.left"], {
|
||||
x: 50,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 400,
|
||||
});
|
||||
});
|
||||
|
||||
test("window rectangles normalize once and project proportionally into a different viewport", () => {
|
||||
assert.deepEqual(
|
||||
normalizeObservationWindowRect(
|
||||
{ x: 100, y: 50, width: 400, height: 200 },
|
||||
{ width: 1_000, height: 500 },
|
||||
),
|
||||
{ x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
|
||||
);
|
||||
});
|
||||
|
||||
test("fullscreen presentation survives the viewport resize it causes", () => {
|
||||
assert.equal(
|
||||
observationPresentationSourceAfterLayoutApply("camera.left", "preserve"),
|
||||
"camera.left",
|
||||
);
|
||||
assert.equal(
|
||||
observationPresentationSourceAfterLayoutApply("camera.left", "reset"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
|
||||
const calls = [];
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
const saved = await saveObservationWorkspaceLayoutProfile(current, {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(JSON.stringify(wireProfile({ revision: 8 })), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, "/api/v1/workspace-layouts/observation.spatial");
|
||||
assert.equal(calls[0].init.method, "PUT");
|
||||
assert.equal(calls[0].init.headers["If-Match"], '"7"');
|
||||
assert.equal(calls[0].body.revision, 7);
|
||||
assert.equal(calls[0].body.workspace_id, "observation.spatial");
|
||||
assert.equal(saved.revision, 8);
|
||||
});
|
||||
|
||||
test("workspace layout API treats 404 as no profile and exposes revision conflicts", async () => {
|
||||
assert.equal(
|
||||
await fetchObservationWorkspaceLayoutProfile({
|
||||
fetcher: async () => new Response(null, { status: 404 }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
await assert.rejects(
|
||||
saveObservationWorkspaceLayoutProfile(current, {
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "revision mismatch" }), {
|
||||
status: 412,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof WorkspaceLayoutApiError &&
|
||||
error.conflict && error.status === 412 && error.message === "revision mismatch",
|
||||
);
|
||||
});
|
||||
|
|
@ -13,8 +13,12 @@ All 1,140 captured `lio_pcl` frames decoded as raw-LZ4 protobuf blocks, yielding
|
|||
4,165,862 points. All 1,215 `lio_pose` messages decoded, and the resulting
|
||||
approximately 1.566 m displacement matched the controlled movement. A later
|
||||
owner-operated LixelGO/iPhone capture also observed left/right RTSP/H.264 camera
|
||||
preview and the remote start/stop wire mapping. The remaining camera work is a
|
||||
bounded Mission Core receiver, not transport discovery.
|
||||
preview and the remote start/stop wire mapping. Mission Core now has a bounded
|
||||
read-only receiver, physical live left/right UI acceptance, acquisition-owned
|
||||
fMP4 archival and a generation-bound recorded player. The remaining camera gate
|
||||
is a newly recorded real K1 session played end-to-end with its point/pose
|
||||
timeline. Full-resolution/raw panorama, device-clock synchronization,
|
||||
intrinsics/extrinsics and remote delivery are still unproven.
|
||||
|
||||
The supplied Bible is useful as an OSINT dossier. It is not an executable plan
|
||||
for the actual stand because many experiments assume a phone and LixelGO. The
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
This plan supersedes the app-dependent experiment order in the reference Bible.
|
||||
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
||||
|
||||
## Live checkpoint — 2026-07-15
|
||||
## Current checkpoint — 2026-07-17
|
||||
|
||||
| Stage | Result |
|
||||
| --- | --- |
|
||||
|
|
@ -15,8 +15,11 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
|||
| Stage 4 artifacts/flows | GO — bounded capture, hashes and negative control |
|
||||
| Stage 5 point cloud | GO — raw-LZ4 protobuf, 1,140 live frames decoded |
|
||||
| Stage 5 pose | GO — 1,215 live frames decoded and motion-correlated |
|
||||
| Stage 5 camera | GO (discovery) — left/right RTSP/H.264 preview observed; runtime adapter pending |
|
||||
| Stage 6 live viewer | GO — React console, Foxglove cloud/path and Mac latency metrics |
|
||||
| Stage 5 camera | GO (live) — left/right RTSP/H.264 preview observed, read-only runtime adapter and physical UI acceptance completed |
|
||||
| Stage 6 live viewer | GO — React Control Station, embedded self-hosted Rerun cloud/trajectory and Mac pipeline metrics |
|
||||
| Stage 7 observation archive | GO (point/pose) — durable catalog, recovery, background RRD preparation, saved-session timeline and atomic playback verified |
|
||||
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; no real archived K1 camera session exists yet |
|
||||
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
||||
|
||||
USB project copying remains optional ground truth rather than a blocker for the
|
||||
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
|
||||
|
|
@ -24,10 +27,14 @@ start/stop mapping and RTSP camera transport. MQTT control publishing remains
|
|||
deliberately deferred because complete request/save/rollback semantics are not
|
||||
yet modeled and the physical button remains a known-safe fallback.
|
||||
|
||||
The Stage 6 alpha uses a bounded raw-first bridge: loss in the visualization
|
||||
queue cannot discard MQTT evidence. Acceptance is replay of the full captured
|
||||
scan followed by a live ideal-LAN run with measured host pipeline latency.
|
||||
Sensor-to-display latency remains a separate clock-correlation test.
|
||||
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
|
||||
queue cannot discard MQTT evidence. Stage 7 adds an independent durable
|
||||
observation-session lifecycle. Completed or recovered native captures are
|
||||
prepared once by a bounded backend worker into digest-bound RRD/cache-v6 and
|
||||
camera-manifest generations; a browser replay request never performs conversion.
|
||||
The saved scene, controller and timeline remain hidden until the complete RRD
|
||||
range and every declared camera pass admission. Sensor-to-display latency and
|
||||
camera/LiDAR sensor-clock alignment remain separate correlation tests.
|
||||
|
||||
## Stage 0 — repository and host baseline
|
||||
|
||||
|
|
@ -213,4 +220,6 @@ useful stream is decoded or structurally identified.
|
|||
- automated scan-button electronics;
|
||||
- OpenWrt/monitor-mode infrastructure;
|
||||
- firmware or internal-Linux analysis;
|
||||
- camera branch if no external frame stream is evidenced.
|
||||
- physical end-to-end replay acceptance for newly archived left/right cameras;
|
||||
- long-running large-session WebViewer/WASM memory telemetry;
|
||||
- production retention, replication, encryption and cross-platform packaging.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# K1 live console and embedded Rerun bridge
|
||||
|
||||
Status: the Rerun alpha milestone is implemented for the verified firmware-3
|
||||
MQTT streams. Live capture and replay use real K1 point-cloud and pose messages;
|
||||
the application does not generate a placeholder cloud, trajectory, camera frame
|
||||
or latency value.
|
||||
Status: the Rerun live and saved-session milestones are implemented for the
|
||||
verified firmware-3 point/pose streams. Live and adapter file-replay use the
|
||||
process-wide gRPC source; saved observation sessions use private digest-bound
|
||||
RRD over same-origin HTTP. Left/right RTSP preview is delivered separately from
|
||||
the spatial Rerun stream. The application does not generate placeholder cloud,
|
||||
trajectory, camera frame or latency values.
|
||||
|
||||
## Active device-to-scene path
|
||||
|
||||
|
|
@ -36,10 +38,12 @@ React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000
|
|||
```
|
||||
|
||||
The Paho MQTT callback does not decode or render the point cloud. It first
|
||||
writes and flushes the raw frame and metadata, then enqueues a preview message.
|
||||
If visualization cannot keep up, the oldest queued preview is discarded while
|
||||
the raw capture continues. Rerun work stays on the dedicated publisher thread
|
||||
and cannot block raw-first evidence capture.
|
||||
appends the raw frame, flushes it to the operating-system page cache and stages
|
||||
its aligned metadata, then enqueues a preview message. Raw bytes and metadata
|
||||
become a durable pair at the bounded group-commit boundary (at most 0.5 s,
|
||||
4 MiB or 32 messages), not at every callback. If visualization cannot keep up,
|
||||
the oldest queued preview is discarded while capture continues. Rerun work
|
||||
stays on the dedicated publisher thread and cannot block that raw-first path.
|
||||
|
||||
The current live/replay runtime instantiates only `RerunBridge`. The former
|
||||
Foxglove implementation is not a parallel runtime and does not listen on TCP
|
||||
|
|
@ -53,7 +57,7 @@ Prerequisites are the repository-local Python environment and Node.js 20.19+ or
|
|||
```bash
|
||||
uv sync --group dev
|
||||
cd apps/control-station
|
||||
npm install
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm run build
|
||||
cd ../..
|
||||
|
|
@ -112,18 +116,20 @@ directly and use their sibling metadata receive timestamps when present.
|
|||
9. Double-click K1 again to stop physical scanning, wait for steady green, then
|
||||
stop the local session so captures and summaries are finalized.
|
||||
|
||||
Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a
|
||||
redacted manifest, operator notes, raw MQTT frames, per-message metadata and a
|
||||
hash summary. The connector subscribes to the fixed report-topic allowlist and
|
||||
does not publish an application request or modeling command.
|
||||
Each new live run creates a direct child below `MISSIONCORE_EVIDENCE_DIR`, or
|
||||
`.runtime/mission-core/evidence/sessions/` by default, with raw MQTT frames,
|
||||
per-message metadata and a hash summary. Repository-level
|
||||
`sessions/*_viewer_live` is legacy import-only evidence and is never selected by
|
||||
the current writer. The connector subscribes to the fixed report-topic
|
||||
allowlist and does not publish an application request or modeling command.
|
||||
|
||||
## Automatic Rerun source and lifecycle
|
||||
|
||||
On the first live or replay session, `RerunBridge`:
|
||||
On the first live or adapter file-replay session, `RerunBridge`:
|
||||
|
||||
- creates an explicit `RecordingStream("nodedc_mission_core_spatial")`;
|
||||
- installs the default spatial blueprint;
|
||||
- starts the gRPC/proxy server on TCP 9876 with a 512 MiB late-client buffer;
|
||||
- starts the gRPC/proxy server on TCP 9876 with a 32 MiB late-client buffer;
|
||||
- reports `rerun+http://127.0.0.1:9876/proxy` only after the server is ready;
|
||||
- accepts the local development and production browser origins used by this
|
||||
repository;
|
||||
|
|
@ -150,7 +156,7 @@ stream directly from the Rerun gRPC/proxy endpoint.
|
|||
| `/world` | `ViewCoordinates` | right-handed Z-up display convention |
|
||||
| `/world/points` | `Points3D` | decoded metric XYZ with computed or available RGB colors |
|
||||
| `/world/sensor_pose` | `Transform3D` + `TransformAxes3D` | current decoded translation, xyzw quaternion and pose axes |
|
||||
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 20,000 decoded poses |
|
||||
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 2,000 decoded poses |
|
||||
|
||||
Point count, frame rate, queue drops and measured pipeline time remain product
|
||||
metrics in the outer Control Station; they are not logged as Rerun entities and
|
||||
|
|
@ -189,9 +195,34 @@ shows only the current frame. RGB mode falls back to the verified scalar
|
|||
coloring path when the active point format has no RGB fields. The current
|
||||
`class`/custom behavior is a selected solid color, not semantic segmentation.
|
||||
|
||||
Projection switching, a custom playback timeline, semantic object/mask layers,
|
||||
camera frustums and persisted RBL/layout profiles are not wired yet and must not
|
||||
be inferred from the implemented controls above.
|
||||
Projection switching, semantic object/mask layers and camera frustums are not
|
||||
wired yet. The host-owned `observation.spatial` layout profile does persist the
|
||||
implemented scene controls, tool-window state, dynamic source visibility and
|
||||
normalized floating-window geometry. It is separate from Rerun's internal
|
||||
blueprint and from observation evidence.
|
||||
|
||||
## Saved observation sessions
|
||||
|
||||
Every completed native live run is indexed by the local host session store. A
|
||||
valid crash prefix without its final summary is indexed as `interrupted`. The
|
||||
**Сохранённые сессии** control shows the three newest runs and launches an
|
||||
opaque same-origin RRD rather than starting a second live bridge.
|
||||
|
||||
Sealing, recovery or legacy import makes the session eligible for the bounded
|
||||
backend preparation worker. The worker reads every native message once and
|
||||
atomically publishes a private cache-v6 RRD using the zero-based `session_time`
|
||||
duration timeline. A SHA sidecar binds the generation to native evidence.
|
||||
Replay-open never performs conversion: it returns HTTP 202 and a preparation
|
||||
handle or reuses the verified artifact. The embedded viewer opens the exact
|
||||
generation through Rerun's native incremental HTTP receiver. The bottom Control
|
||||
Station timeline then controls play/pause and seek directly; it does not
|
||||
approximate time with a React timer.
|
||||
|
||||
The native `.k1mqtt` remains the evidence master. RRD generation does not use
|
||||
the bounded live-preview queue, so it retains every frame accepted by the
|
||||
reviewed normalizer. See
|
||||
[`09_OBSERVATION_SESSIONS.md`](09_OBSERVATION_SESSIONS.md) for storage,
|
||||
recovery and HTTP Range details.
|
||||
|
||||
## Time and latency semantics
|
||||
|
||||
|
|
@ -228,7 +259,7 @@ and host firewall as appropriate, and never forward it directly to the public
|
|||
Internet or cellular WAN. Remote operation requires an authenticated TLS reverse
|
||||
proxy or another reviewed secure transport before deployment.
|
||||
|
||||
The 512 MiB Rerun server buffer limits retained late-client data but is not an
|
||||
The 32 MiB Rerun server buffer limits retained late-client data but is not an
|
||||
access-control mechanism. The listener and embedded viewer intentionally remain
|
||||
ready between acquisition sessions. Stop `k1link serve` when the network
|
||||
listener and its process memory must be closed unconditionally.
|
||||
|
|
@ -236,8 +267,10 @@ listener and its process memory must be closed unconditionally.
|
|||
## Current boundaries
|
||||
|
||||
- Raw panoramic camera frames were absent from the observed MQTT report topics.
|
||||
This milestone contains point cloud and pose/trajectory; operational metrics
|
||||
are rendered by the outer Control Station.
|
||||
The Rerun spatial recording contains point cloud and pose/trajectory;
|
||||
left/right compressed RTSP preview and acquisition-owned fMP4 archive use a
|
||||
separate generic media path. Historical sessions predating that archive have
|
||||
no recoverable video.
|
||||
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
|
||||
publisher needs a separately reviewed state-changing profile.
|
||||
- No terrain map, elevation model, obstacle segmentation, localization fusion,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ already exist. The accepted device-lifecycle decision is recorded in
|
|||
| `src/k1link/protocol/` | Firmware-scoped K1 codecs and explicit vendor normalizer | Plugin-owned protocol adapter |
|
||||
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
|
||||
| `src/k1link/viewer/` | Rerun consumer, visualization runtime and legacy Foxglove regression module | Replaceable canonical scene sink and presentation adapters |
|
||||
| `src/k1link/sessions/` | Host-owned SQLite catalog, evidence discovery/recovery, background preparation and derived-cache lifecycle | Device-neutral observation archive service |
|
||||
| `src/k1link/web/session_api.py` | Opaque saved-session, immutable RRD/media and workspace-layout API | Versioned Control/Edge observation contract |
|
||||
| `apps/control-station/src/core/observation/` | Session selection, replay admission, recorded-source and layout state | Device-neutral observation UI runtime |
|
||||
| `docs/domain-model/` | Versioned experimental Mission Core vocabulary | Promotion source for meanings proven across devices/services |
|
||||
|
||||
## Implemented boundary
|
||||
|
|
@ -33,6 +36,17 @@ raw K1 transport
|
|||
-> embedded local viewer
|
||||
```
|
||||
|
||||
The host-owned recorded path is separate from the disposable live preview:
|
||||
|
||||
```text
|
||||
sealed or recovered native observation session
|
||||
-> SQLite catalog and bounded single-worker preparation
|
||||
-> atomic RRD cache v6 + immutable recorded-media manifest v2
|
||||
-> generation-bound same-origin HTTP
|
||||
-> aggregate RRD/camera admission
|
||||
-> native Rerun HTTP receiver + recorded fMP4 player
|
||||
```
|
||||
|
||||
Rerun does not import K1 protocol code, inspect MQTT topics or receive raw
|
||||
payloads. `VisualizationRuntime` cannot choose a vendor decoder implicitly; its
|
||||
composition owner injects one. The local `Decoded*View` classes are in-process
|
||||
|
|
@ -52,6 +66,8 @@ The local vocabulary distinguishes:
|
|||
```text
|
||||
model != device != transport alias != device session
|
||||
acquisition != operation != source/channel != evidence
|
||||
observation session != preparation job != replay launch != viewer instance
|
||||
workspace layout != sensor evidence
|
||||
acknowledgement != completion
|
||||
raw evidence != decoded data != viewer state
|
||||
```
|
||||
|
|
@ -103,10 +119,14 @@ by physical double-click; no modeling request is published.
|
|||
Left/right camera preview transport, endpoint paths and H.264 framing are now
|
||||
observed under the exact compatibility profile. The local read-only adapter
|
||||
copy-remuxes one selected RTSP producer into bounded fMP4/WebSocket delivery for
|
||||
the generic MSE UI. Portable FFmpeg packaging, fan-out and remote delivery remain
|
||||
open. Device status and heartbeat remain raw observed channels without semantic
|
||||
decoders. Device calibration command and sensor-to-vehicle extrinsics are
|
||||
unavailable.
|
||||
the generic MSE UI. New acquisitions archive selected-camera init/segments/index
|
||||
independently of browser delivery. The generic recorded player, manifest-v2
|
||||
validation and shared `session_time` controls are connected; historical sessions
|
||||
created before this archive contract contain no recoverable video, so physical
|
||||
recorded-camera acceptance remains open. Portable FFmpeg packaging, disk-backed
|
||||
browser buffering, fan-out and remote delivery also remain open. Device status
|
||||
and heartbeat remain raw observed channels without semantic decoders. Device
|
||||
calibration command and sensor-to-vehicle extrinsics are unavailable.
|
||||
|
||||
The 2026-07-17 physical acceptance gate confirmed continuously updating point
|
||||
clouds, a matching live trajectory, and both camera selections in the same
|
||||
|
|
@ -128,14 +148,17 @@ for the measured gate and remaining limits.
|
|||
5. Replace compatibility routes and singleton state with multi-device session
|
||||
routing.
|
||||
6. Split Edge execution from the Control Station behind authenticated transport.
|
||||
7. Package the read-only RTSP/H.264 camera adapter for each target OS and evolve
|
||||
same-host MSE delivery toward an authenticated Edge media plane; keep the
|
||||
modeling-command publisher disabled until its separate safety gate closes.
|
||||
7. Physically accept a newly archived left/right K1 session, then package the
|
||||
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
|
||||
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
||||
plane; keep the modeling-command publisher disabled until its separate safety
|
||||
gate closes.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Raw evidence is persisted before preview work and is never overwritten by a
|
||||
decode result.
|
||||
- Raw bytes are appended and OS-flushed before preview work; aligned raw and
|
||||
metadata become durable together at the bounded group-commit boundary and
|
||||
are never overwritten by a decode result.
|
||||
- Mission Core starts when the XGRIDS manifest is absent. Formal installed
|
||||
plugin quarantine remains a future supervisor feature.
|
||||
- Core code does not branch on XGRIDS IDs, fields, topics or firmware.
|
||||
|
|
@ -160,8 +183,13 @@ for the measured gate and remaining limits.
|
|||
- complete SDK-envelope/EvidenceStore hot-path integration;
|
||||
- remote Edge split, authenticated WAN relay and fleet orchestration;
|
||||
- automatic K1 start/stop/calibration commands;
|
||||
- automatic camera capability discovery, synchronized recording/rewind,
|
||||
calibration, panoramic stitching and multi-consumer delivery.
|
||||
- device-reported camera capability discovery beyond the reviewed K1 profile;
|
||||
- physical acceptance of shared-timeline recorded-camera playback on a newly
|
||||
archived K1 session;
|
||||
- frame-accurate camera/LiDAR calibration, panoramic stitching, disk-backed
|
||||
browser media cache and remote multi-consumer delivery;
|
||||
- production retention, replication, encryption and long-run WebViewer/WASM
|
||||
memory acceptance for large observation sessions.
|
||||
|
||||
## Workspace-repeatable locked architecture gate
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,421 @@
|
|||
# Observation sessions, playback and workspace layout
|
||||
|
||||
Status: implemented for native K1 point/pose evidence and host-side camera
|
||||
archival. Recorded spatial playback is exposed in the Mission Core observation
|
||||
workspace. The camera archive/player contract is implemented and covered by
|
||||
tests, but all retained real K1 sessions predate canonical camera archival.
|
||||
Physical archived-camera playback therefore remains an open acceptance gate;
|
||||
historical sessions contain no recoverable video.
|
||||
|
||||
## Operator path
|
||||
|
||||
1. Start a normal acquisition from **Парк → Локальное устройство**.
|
||||
2. Open **Наблюдение → Пространственная сцена**. Live point cloud, trajectory
|
||||
and the selected camera remain live-only while acquisition is running.
|
||||
3. Stop acquisition normally, or allow the local service to recover an
|
||||
unexpected interruption on its next start. Session recording is automatic;
|
||||
the disk action is not required.
|
||||
4. Open **Сохранённые сессии** in the observation header. The menu shows the
|
||||
three most recent indexed runs, their date, duration, state, modalities and
|
||||
background preparation state.
|
||||
5. Choose a replayable run. Opening a replay never performs conversion in the
|
||||
request. A ready recording opens immediately; otherwise the client receives
|
||||
HTTP 202, keeps the current scene mounted and polls the preparation status.
|
||||
Only after the server returns a verified launch descriptor does Mission Core
|
||||
pause and unload the previous viewer.
|
||||
6. The client then receives and decodes the complete RRD. The new viewport and
|
||||
timeline stay hidden until the declared recording range is fully buffered
|
||||
(`fullyBuffered`). Playback starts once at that point. Partial frames are
|
||||
never shown. If the run contains canonical camera archives, its recorded
|
||||
camera sources replace all live device overlays.
|
||||
7. Use **Воспроизвести / Пауза**, the scrubber, **К началу** and **К концу** on
|
||||
the right half of the bottom timeline. The left half changes point-cloud
|
||||
accumulation. The recorded timeline is zero-based `session_time`.
|
||||
8. Use the first disk button to save the current workspace layout. The next
|
||||
opening restores display settings, tool windows and dynamic source-window
|
||||
positions. This action saves no sensor evidence.
|
||||
|
||||
Display controls have no separate Apply/Reset transaction. Boolean controls
|
||||
commit immediately; sliders, colors and selects commit on release/blur or after
|
||||
a short quiet period, and concurrent commits collapse to the newest value. A
|
||||
layout save flushes and awaits this queue before serializing the confirmed
|
||||
settings. Recorded display updates reuse stable Rerun scene identifiers and do
|
||||
not mutate playback state, so changing accumulation, size, color or visibility
|
||||
does not pause the active replay or replace its camera view.
|
||||
|
||||
Completed and recovered sessions are discovered on startup and by the catalog
|
||||
reconciler, then deduplicated into one bounded, single-worker preparation queue.
|
||||
The worker validates the native source, exports point/pose data, verifies source
|
||||
stability and both SHA-256 digests, finalizes once, and atomically publishes the
|
||||
digest-bound derived RRD plus its cache sidecar. Before the same job becomes
|
||||
`ready`, it also parses and validates every archived camera index and retains
|
||||
the immutable manifest generation in memory. The catalog exposes preparation
|
||||
state and progress while this runs; a recording URL and camera descriptors are
|
||||
returned only after the complete launch generation has passed validation.
|
||||
|
||||
Preparation is a backend lifecycle, not a viewer action: sealing or recovering
|
||||
a native session makes it eligible for the reconciler, which builds the derived
|
||||
RRD once in the background. Reopening, seeking or changing the workspace reads
|
||||
that prepared artifact and never reruns conversion. A cold process may validate
|
||||
an existing cache and rebuild its in-memory camera manifest in the worker, but
|
||||
no replay, status, RRD or manifest request hashes native evidence or parses a
|
||||
camera index synchronously.
|
||||
|
||||
The session menu uses three operator indicators:
|
||||
|
||||
| Indicator | Catalog/preparation state | Meaning |
|
||||
| --- | --- | --- |
|
||||
| Solid green + `Готово` | `ready` | Verified RRD is available. |
|
||||
| Pulsing green + `Обработка` | `queued`, `validating`, `exporting`, `finalizing` | Background preparation is active. |
|
||||
| Dim gray + `Ошибка` | failed, cancelled, non-replayable or invalid session | No launchable recording; inspect the message or retry. |
|
||||
|
||||
Saved-session status never uses yellow or red. Switching sessions aborts only
|
||||
the obsolete browser poll; it does not cancel the process-owned conversion.
|
||||
A queued job may legitimately wait behind another export and therefore uses the
|
||||
overall preparation deadline rather than the active-export heartbeat timeout.
|
||||
|
||||
The first preparation of a long capture can take time because every decodable
|
||||
point/pose message is projected into RRD. Later openings reuse a verified,
|
||||
digest-bound cache. Derived RRD cache v6 is intentionally incompatible with v4
|
||||
and v5 because only v6 guarantees that the payload itself contains a real
|
||||
`session_time = 0` anchor; older generations are rebuilt once in the background.
|
||||
The browser may use the strict `source_url` with `If-Match`, while the embedded
|
||||
Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256
|
||||
`generation` query is bound to the same launch descriptor. A missing or stale
|
||||
generation fails with `412`; a matching URL is served with exact length, strong
|
||||
ETag and private immutable/no-transform caching. Every cache pin is released when the
|
||||
HTTP response completes, disconnects or fails, so an interrupted switch cannot
|
||||
make a derived recording permanently non-evictable. A bounded 120-second launch
|
||||
reservation also protects the artifact between the verified launch response and
|
||||
the WebViewer's subsequent RRD request, including a cold WASM startup; it
|
||||
expires automatically if that request never arrives.
|
||||
|
||||
## Storage roots
|
||||
|
||||
By default, both catalog state and new source evidence are private to the
|
||||
checkout:
|
||||
|
||||
```text
|
||||
.runtime/mission-core/
|
||||
├── mission-core.sqlite3
|
||||
├── mission-core.sqlite3-shm
|
||||
├── mission-core.sqlite3-wal
|
||||
├── evidence/
|
||||
│ └── sessions/
|
||||
│ ├── .current_session # present only while a writer owns the root
|
||||
│ └── <UTC>_viewer_live/
|
||||
│ ├── captures/ # native MQTT source evidence
|
||||
│ └── media/ # canonical camera archives
|
||||
└── recordings/
|
||||
├── .export.lock # cross-process conversion/eviction lock
|
||||
└── <opaque-session-id>/
|
||||
├── scene.rrd
|
||||
└── scene.rrd.cache.json
|
||||
```
|
||||
|
||||
`MISSIONCORE_DATA_DIR` relocates the catalog and derived cache. Unless it is
|
||||
overridden separately, new evidence is written to
|
||||
`$MISSIONCORE_DATA_DIR/evidence/sessions`. `MISSIONCORE_EVIDENCE_DIR` can select
|
||||
another absolute MQTT evidence root. In the current K1 integration the camera
|
||||
gateway additionally confines its recording directory to the repository
|
||||
checkout, so a full point-plus-camera acquisition must keep the evidence root
|
||||
inside that checkout. An external evidence volume currently supports MQTT
|
||||
capture/cataloging but camera archival will fail closed until the gateway gets
|
||||
a separately attested storage root:
|
||||
|
||||
```bash
|
||||
export MISSIONCORE_DATA_DIR=/absolute/private/path/mission-core
|
||||
# Full K1 point-plus-camera evidence must currently remain below the checkout.
|
||||
export MISSIONCORE_EVIDENCE_DIR=/absolute/path/to/NODEDC_MISSION_CORE/.runtime/mission-core/evidence/sessions
|
||||
export MISSIONCORE_RRD_CACHE_MAX_BYTES=8589934592
|
||||
export MISSIONCORE_RRD_FREE_SPACE_RESERVE_BYTES=2147483648
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
The directories and derived recording cache use owner-only permissions where
|
||||
the host filesystem permits them. A new acquisition writer is assigned only a
|
||||
direct child of the private evidence root; it no longer writes a new run to the
|
||||
repository-level `sessions/` directory.
|
||||
|
||||
Repository-level `sessions/*_viewer_live` runs are a legacy, import-only source
|
||||
for the catalog. They are discovered and confined in place: refresh does not
|
||||
copy, move or migrate their large payloads, and no current writer is assigned
|
||||
that root. Startup may recovery-seal an already existing, incomplete canonical
|
||||
camera epoch there by preserving its valid segment prefix and writing an
|
||||
`interrupted` summary; this is evidence recovery, not a new acquisition write.
|
||||
|
||||
Never add `.runtime/`, `sessions/`, raw captures, RRD files or camera media to
|
||||
Git. They can contain mapped interiors, trajectories and identifiable images.
|
||||
|
||||
## Session source of record
|
||||
|
||||
For the current K1 profile:
|
||||
|
||||
```text
|
||||
MQTT callback
|
||||
├─ durable native .k1mqtt + aligned metadata (source of record)
|
||||
└─ bounded latest-wins Rerun live preview (disposable)
|
||||
|
||||
selected RTSP producer
|
||||
├─ durable init + fMP4 segments + JSONL index (camera evidence)
|
||||
└─ bounded WebSocket/MSE preview (disposable)
|
||||
```
|
||||
|
||||
The live preview is intentionally allowed to drop frames under load. Native
|
||||
point/pose evidence and camera archive writes do not traverse that queue.
|
||||
|
||||
Native `.k1mqtt` bytes with aligned metadata and canonical camera fMP4 archives
|
||||
are the evidence source of truth. The derived RRD contains every decodable point
|
||||
and pose frame from that source, but remains a rebuildable view rather than an
|
||||
evidence master. A cache entry is rebuilt only when native source
|
||||
identity/digests change or an incompatible derived-data export revision is
|
||||
introduced. A UI blueprint or workspace-layout revision never invalidates or
|
||||
rewrites the data RRD. Cache v4/v5 payloads are not reusable as v6 because they
|
||||
do not guarantee the real zero-time anchor.
|
||||
|
||||
Expensive cache misses run through the bounded preparation worker and one global
|
||||
cross-process export gate to cap peak RAM, CPU and temporary-disk use. Crash
|
||||
leftovers from candidates, exporter temporary files and staged replay prefixes
|
||||
are scavenged under that lock before quota accounting. Ready cache hits and
|
||||
active response leases do not wait behind that gate. The derived cache has an
|
||||
8 GiB default quota, preserves a 2 GiB default filesystem reserve and evicts
|
||||
least-recently-used RRDs only; it never deletes native evidence.
|
||||
|
||||
## Camera archive contract
|
||||
|
||||
New acquisitions archive each selected source and codec epoch below the same
|
||||
private evidence session:
|
||||
|
||||
```text
|
||||
<evidence-root>/sessions/<session-id>/media/<stable-source-id>/epoch-1/
|
||||
├── init.mp4
|
||||
├── segments/
|
||||
│ ├── 1.m4s
|
||||
│ └── ...
|
||||
├── index.jsonl
|
||||
└── summary.json
|
||||
```
|
||||
|
||||
Each index row binds a segment sequence, byte length, SHA-256 and host arrival
|
||||
epoch/monotonic timestamps. Camera durability uses a
|
||||
`per-segment-fsync` policy: `init.mp4` and every complete media segment are
|
||||
individually fsynced and their directory entries synchronized before the
|
||||
matching JSONL index row is committed. The index and an atomic `interrupted`
|
||||
checkpoint summary are fsynced before the append returns. The old interval and
|
||||
byte constructor options are compatibility-only and cannot weaken this
|
||||
segment-bound RPO. A power failure may still lose or leave uncommitted the
|
||||
fragment currently being produced; it cannot make a committed index row point
|
||||
past durable media. Camera windows may close or reconnect without terminating
|
||||
archival while the owning acquisition remains active.
|
||||
|
||||
Synchronization is `host-arrival-best-effort`: LiDAR/MQTT and camera segments
|
||||
share the Mac host clock boundary, but K1 sensor exposure time and LiDAR firing
|
||||
time are not proven to use a shared device clock. Do not infer frame-accurate
|
||||
calibration from the playback timeline.
|
||||
|
||||
Finalized media is prepared once by the same process-owned background job that
|
||||
materializes the RRD. The gateway records host time only after a complete
|
||||
`moof+mdat` fragment has arrived, so that timestamp is an availability/end
|
||||
anchor, never a fragment-start timestamp. Preparation reads and SHA-verifies
|
||||
every fragment, parses bounded ISO-BMFF timing tables (`mdhd`, `trex`, `tfhd`,
|
||||
`trun`), anchors the epoch at
|
||||
`max(0, first_arrival - first_fragment_duration)`, and sets its end to that start
|
||||
plus the checked sum of every decoded fragment duration. The declared interval
|
||||
therefore has exactly the duration MSE is expected to expose and is not stretched
|
||||
by host scheduling jitter.
|
||||
|
||||
Epoch arrivals must be strictly monotonic; epoch intervals must be finite,
|
||||
monotonic and non-overlapping. Replay v2 also requires every media interval to
|
||||
fit inside the spatial RRD interval with a 50 ms numeric tolerance; it fails
|
||||
preparation instead of clamping unreachable evidence. A future replay v3 must
|
||||
separate `spatial_range` from a session-wide union range before out-of-RRD camera
|
||||
coverage can be navigated. A missing, ambiguous, oversized or otherwise
|
||||
unparseable timing table fails preparation; the archive remains evidence but is
|
||||
never advertised as seekable media.
|
||||
|
||||
The prepared path-free v2 descriptor and full source stat identity (native raw
|
||||
and timing metadata plus every camera summary, index, init and segment file) are
|
||||
written under the private derived cache with a schema, generation and checksum.
|
||||
Publication uses a private temporary file, file and directory `fsync`, and atomic
|
||||
rename; startup scavenges crash-left temporary files. A restart reuses this
|
||||
sidecar after confined O(n) stat validation, without rereading, hashing or parsing
|
||||
media. A missing, corrupt or stale sidecar is rebuilt only by the background
|
||||
worker. No replay, status, manifest or payload request performs conversion or
|
||||
recalculates these intervals.
|
||||
|
||||
Every derived RRD contains a real `session_time = 0` row at the internal
|
||||
`/__mission_core/session_origin` entity. The anchor is deliberately outside
|
||||
`/world`, so it establishes the actual recording time range without creating a
|
||||
3D layer or drawable scene object. Preparation verifies the derived recording
|
||||
against the declared spatial range rather than relying on summary metadata
|
||||
alone.
|
||||
|
||||
Sessions made before this archive contract have no recoverable video even if a
|
||||
camera preview was visible at the time. In particular, the 2026-07-16 browser
|
||||
camera acceptance run retained point/pose evidence only.
|
||||
|
||||
## Crash and interruption behavior
|
||||
|
||||
- SQLite uses WAL, foreign keys and full synchronous durability.
|
||||
- Before creating a session directory, the acquisition takes an exclusive,
|
||||
cross-process lease by atomically creating and locking
|
||||
`<evidence-root>/.current_session`. The marker contains only the direct child
|
||||
session name. While the lock is held, a second writer fails closed and
|
||||
discovery excludes that in-progress session from replay.
|
||||
- A process exit releases the operating-system lock. On the next service start,
|
||||
stale-marker recovery removes the marker only after it can take the lock and
|
||||
revalidate the marker inode without following symlinks. It never deletes the
|
||||
interrupted session directory; normal catalog recovery then evaluates the
|
||||
durable prefix. A locked/live or suspicious marker is left untouched.
|
||||
- Native MQTT writes use a bounded group commit: at most 0.5 seconds, 4 MiB or
|
||||
32 messages per group. Raw bytes are fsynced before their metadata rows are
|
||||
written and fsynced. This is a bounded RPO, not a zero-loss guarantee: a hard
|
||||
process or power failure may discard the final uncommitted group. Raw bytes
|
||||
from an interrupted commit window may survive beyond the durable metadata;
|
||||
replay uses only the last validated metadata-aligned raw boundary.
|
||||
- A native capture with a missing final summary is accepted only when the raw
|
||||
and metadata prefix is bounded, aligned and structurally valid. It is cataloged
|
||||
as `interrupted`, never silently promoted to `ready`.
|
||||
- A non-newline metadata crash tail can be ignored. Newline-terminated or
|
||||
mid-file corruption fails closed.
|
||||
- Camera recovery retains a contiguous valid segment prefix, quarantines
|
||||
non-contiguous/orphan fragments instead of deleting them, rebuilds the index
|
||||
when necessary and writes an `interrupted` summary. Unindexed bytes are not
|
||||
presented as valid media.
|
||||
- If a valid normal summary appears later, repeat discovery updates the same
|
||||
session to `ready`.
|
||||
- Materialization detects a native capture changing during export and refuses
|
||||
to publish the derived RRD. It reopens the prepared raw source with no symlink
|
||||
following inside the cataloged session roots, and interrupted recordings are
|
||||
materialized from the last validated raw/metadata boundary only.
|
||||
- Internal preparation cancellation is cooperative. A queued job cancels
|
||||
immediately; an active job stops at a safe checkpoint, removes its candidate
|
||||
and never publishes a partial RRD. The operator UI does not cancel automatic
|
||||
preparation when switching sessions or closing a tab: preparation belongs to
|
||||
the application worker, not to an HTTP request.
|
||||
- `failed` and `cancelled` are terminal, retryable states. The API exposes a
|
||||
sanitized error, and an explicit retry creates a fresh job for the current
|
||||
source identity. A stalled poll or failed switch leaves the current scene
|
||||
mounted; the latest operator selection wins over obsolete responses.
|
||||
- A lifecycle shutdown marks only its own interrupted work for automatic
|
||||
reconciliation after restart. An operator cancellation and a genuine failed
|
||||
export remain terminal and are never retried forever by the reconciler.
|
||||
|
||||
These rules provide crash recovery, not replication. A single host disk failure
|
||||
can still destroy local data. Vehicle deployment must add independent onboard
|
||||
and control-station copies, capacity monitoring and a documented retention
|
||||
policy.
|
||||
|
||||
## HTTP API
|
||||
|
||||
All paths are same-origin and expose opaque identifiers only:
|
||||
|
||||
```text
|
||||
GET /api/v1/observation-sessions?limit=3
|
||||
GET /api/v1/observation-sessions/{id}
|
||||
POST /api/v1/observation-sessions/{id}/replay
|
||||
GET /api/v1/observation-sessions/{id}/recording-preparation
|
||||
DELETE /api/v1/observation-sessions/{id}/recording-preparation
|
||||
GET /api/v1/observation-sessions/{id}/recording.rrd
|
||||
POST /api/v1/observation-sessions/{id}/blueprint.rrd
|
||||
GET /api/v1/observation-sessions/{id}/media/{artifact}/manifest
|
||||
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/init.mp4
|
||||
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/segments/{m}.m4s
|
||||
|
||||
GET /api/v1/workspace-layouts/observation.spatial
|
||||
PUT /api/v1/workspace-layouts/observation.spatial
|
||||
```
|
||||
|
||||
The catalog embeds each replayable session's preparation state and progress.
|
||||
`POST .../replay` returns either a verified replay v2 launch document or HTTP
|
||||
202 with the preparation v1 document, `Location`, `Retry-After`, an exact quoted
|
||||
preparation `ETag` and a same-origin status URL. Polling
|
||||
`GET .../recording-preparation` sends that value as `If-Match` and returns HTTP
|
||||
202 while the job is active, HTTP 409 for retryable `failed`/`cancelled` states,
|
||||
and the launch document only when the same job and artifact are ready; every
|
||||
response echoes the same `ETag`. `DELETE` also requires `If-Match`, so a stale
|
||||
tab cannot cancel a replacement job. Conversion is never executed synchronously
|
||||
by a replay-open request. Playback speed and loop are request-local launch
|
||||
policy; they are not stored on or shared through a preparation job.
|
||||
|
||||
Production `recording.rrd` access is bound to the launch generation. The client
|
||||
can use the strict query-free URL with exact strong
|
||||
`If-Match: "sha256:<launch.sha256>"`; a request with neither an `If-Match` nor a
|
||||
generation returns 428. The embedded Rerun receiver instead gets only the
|
||||
canonical `viewer_source_url = source_url + "?generation=<launch.sha256>"`.
|
||||
The server returns 412 for a malformed or replaced generation before opening
|
||||
the body. A successful response echoes the strong ETag, exact `Content-Length`,
|
||||
`application/vnd.rerun.rrd` and immutable private/no-transform cache policy.
|
||||
Rerun's native HTTP receiver owns incremental decoding; `LogChannel.send_rrd`
|
||||
is reserved for independently complete RRD payloads such as the small generated
|
||||
blueprint and must never receive arbitrary HTTP byte slices. The canvas,
|
||||
timeline, controller and autoplay remain closed until the decoded spatial range
|
||||
exactly matches the launch descriptor and every declared camera is ready. API
|
||||
documents never contain local paths. Layout updates require the quoted current
|
||||
revision in `If-Match`; stale writers receive HTTP 412 rather than overwriting
|
||||
another saved profile.
|
||||
|
||||
Recorded media routes expose only opaque catalog identifiers and ordinal codec
|
||||
epochs. The required `missioncore.observation-recorded-media/v2` manifest carries
|
||||
a strong `generation_sha256`, exact JS-safe aggregate `byte_length`, and finite
|
||||
`timeline_start_seconds` / `timeline_end_seconds` for every epoch. Epoch ends
|
||||
participate in the generation digest. Every epoch also declares its init byte
|
||||
length/digest and a complete contiguous segment list with sequence, URL, byte
|
||||
length and digest. The launch source repeats the same aggregate `byte_length`
|
||||
and uses exactly `max(epoch.timeline_end_seconds)` as its end; the spatial RRD
|
||||
end must never pad camera coverage. The browser cross-checks launch, manifest and
|
||||
component lengths. The current browser laboratory policy accepts at most 16
|
||||
camera sources, 128 MiB per source and 512 MiB across the session. This launch
|
||||
preflight finishes before the first camera manifest/init/segment GET; admitted
|
||||
cameras are then downloaded and decode-probed one at a time. Verified raw
|
||||
buffers remain immutable so route changes and player remounts cannot append
|
||||
emptied data. This bounded in-memory strategy is deliberate for the laboratory
|
||||
milestone; an OPFS-backed sealed-generation cache is the next scaling step for
|
||||
larger rigs and must preserve the same launch/manifest/hash admission contract.
|
||||
|
||||
The manifest response ETag is the exact generation, and the manifest GET itself
|
||||
requires that generation in `If-Match`. Init and media GETs likewise require the
|
||||
descriptor's exact SHA `If-Match`, then open through a confined directory file
|
||||
descriptor with no symlink following, verify the declared SHA-256 and serve
|
||||
exact-length bytes with immutable private `no-transform` caching and byte-range
|
||||
support. Physical source ids, RTSP addresses and storage paths never cross the
|
||||
API boundary.
|
||||
|
||||
## Current limit
|
||||
|
||||
The spatial RRD, trajectory and archived fMP4 cameras use the same operator
|
||||
scrubber now. Camera epochs are aligned to zero-based `session_time` from the
|
||||
shared host-arrival monotonic clock and rendered through MSE. The client selects
|
||||
an epoch only inside its declared inclusive interval and verifies that the
|
||||
decoded MSE seekable duration covers that interval. This remains best-effort
|
||||
correlation: codec PTS and K1 sensor exposure time are not proven to share the
|
||||
LiDAR clock. A codec epoch whose init segment does not expose a browser-supported
|
||||
codec or whose timing cannot be proven remains retained evidence and fails
|
||||
closed in the UI/background preparation.
|
||||
Historical sessions with no canonical camera archive honestly show no recorded
|
||||
video. Recorded blueprints can change accumulation, grid visibility, point and
|
||||
trajectory visibility, point radius and a uniform custom point color without
|
||||
rewriting the recording. The client deliberately has no progressive recorded
|
||||
mode: the viewport, timeline and autoplay gate remain closed until the complete
|
||||
declared RRD has been received and decoded. Height, intensity, distance and RGB
|
||||
palettes remain baked into current RRD rows; fully dynamic recoloring requires
|
||||
exporting the corresponding scalar components in a future recording schema.
|
||||
|
||||
## Verification checkpoint — 2026-07-17
|
||||
|
||||
The repository state described above passed the complete local pre-push gate:
|
||||
|
||||
- `uv sync --frozen --group dev` completed against the locked Python graph;
|
||||
- `uv run pytest`: **284 passed**;
|
||||
- `uv run ruff check .`: clean;
|
||||
- project mypy and strict plugin-SDK mypy: clean across 51 and 12 source files;
|
||||
- the XGRIDS K1 profile loader and emitted v0alpha2 JSON schema validated;
|
||||
- clean `npm ci` ran the required Rerun 0.34.1 postinstall patch;
|
||||
- frontend unit suite: **111 passed, 0 failed**;
|
||||
- TypeScript project build and Vite production build completed;
|
||||
- the generated `dist/index.html` and non-empty Rerun WASM artifact were verified.
|
||||
|
||||
Vite still reports its expected large-chunk warning for the embedded Rerun
|
||||
viewer/WASM payload. That is a packaging optimization item, not a failed gate.
|
||||
No retained physical K1 session contains the new canonical camera archive yet,
|
||||
so real recorded-camera playback remains an explicit hardware acceptance test.
|
||||
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
Status: accepted for the experimental Mission Core runtime, 2026-07-16.
|
||||
|
||||
Scope update, 2026-07-17: the live device descriptors in this ADR remain
|
||||
live-only. ADR 0008 adds a separate host-owned recorded-source catalog,
|
||||
seekable `session_time` playback and workspace layout; it does not retroactively
|
||||
turn a live RTSP lease into recorded evidence.
|
||||
|
||||
## Context
|
||||
|
||||
The spatial scene and camera workspace must accept one device, a custom rig or a
|
||||
|
|
@ -68,10 +73,12 @@ RTSP/FFmpeg generation before a new browser lease is issued.
|
|||
|
||||
The local laboratory adapter now terminates RTSP/RTP with FFmpeg, copy-remuxes
|
||||
H.264 High L4 without transcoding and publishes complete bounded fMP4 segments
|
||||
to a generic MSE player. Portable FFmpeg packaging, multi-consumer fan-out and
|
||||
remote/WAN delivery are not claimed by this milestone. Synchronized rewind
|
||||
becomes eligible only after persisted buffering and an evidenced mapping between
|
||||
the RTP 90 kHz clock and the spatial stream clock. See ADR 0007.
|
||||
to a generic MSE player. ADR 0008 subsequently added acquisition-owned durable
|
||||
segments, a prepared recorded-media manifest and host-arrival-best-effort rewind
|
||||
on the saved-session timeline. This is not proof of a shared device clock or
|
||||
frame-accurate LiDAR/camera alignment. Portable FFmpeg packaging,
|
||||
multi-consumer fan-out and remote/WAN delivery remain open. See ADR 0007 and
|
||||
ADR 0008.
|
||||
|
||||
## Code anchors
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
Status: accepted for the local laboratory runtime, 2026-07-16.
|
||||
|
||||
Supersession note, 2026-07-17: the live copy-remux and lease decisions remain
|
||||
active. ADR 0008 extends the same acquisition-owned producer with durable fMP4
|
||||
epochs and a generic recorded player. That extension does not weaken the
|
||||
single-live-producer or opaque transport boundary defined here.
|
||||
|
||||
## Context
|
||||
|
||||
The exact K1 firmware 3.0.2 profile exposes two RTSP/TCP H.264 preview paths.
|
||||
|
|
@ -73,16 +78,22 @@ timeline once when the recording opens and does not poll or force the cursor.
|
|||
- Same-origin local WebSocket delivery is not the future WAN transport. A later
|
||||
Edge/Control Station split should use an authenticated WebRTC/WHEP or equivalent
|
||||
congestion-aware media plane behind the same descriptor contract.
|
||||
- Camera recording, synchronized rewind, calibration, frustums, panoramic
|
||||
stitching and full-resolution raw imagery remain unavailable.
|
||||
- Durable camera recording and host-arrival-best-effort saved-session rewind are
|
||||
implemented under ADR 0008, but no retained real K1 session made after that
|
||||
archive contract has completed physical recorded-camera acceptance yet.
|
||||
- Device-clock synchronization, calibration, frustums, panoramic stitching,
|
||||
full-resolution raw imagery and remote multi-consumer delivery remain
|
||||
unavailable.
|
||||
- A longer physical soak test is still required to characterize sustained
|
||||
preview backpressure and rendering cost on target Edge hardware.
|
||||
|
||||
## Code anchors
|
||||
|
||||
- `src/k1link/web/xgrids_k1_camera.py`
|
||||
- `src/k1link/web/camera_archive.py`
|
||||
- `src/k1link/web/xgrids_k1_facade.py`
|
||||
- `apps/control-station/src/components/MseFmp4WebSocketPlayer.tsx`
|
||||
- `apps/control-station/src/components/RecordedFmp4Player.tsx`
|
||||
- `apps/control-station/src/core/observation/useObservationLayout.ts`
|
||||
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`
|
||||
- `tests/test_xgrids_camera_gateway.py`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
# ADR 0008: durable observation sessions and workspace layout
|
||||
|
||||
- Status: accepted for the laboratory Control Station
|
||||
- Date: 2026-07-17
|
||||
- Scope: host observation storage, recorded spatial playback, camera evidence,
|
||||
and operator workspace layout
|
||||
|
||||
## Context
|
||||
|
||||
Mission Core previously retained K1 live point/pose captures as ignored
|
||||
laboratory directories, while the browser showed only the current process-wide
|
||||
Rerun stream. Camera preview was copy-remuxed for a connected browser but was
|
||||
not part of the durable session. A browser refresh, local service restart or an
|
||||
unexpected process exit therefore made a completed run difficult to discover
|
||||
and made camera history impossible to recover. The disk icon in the spatial
|
||||
workspace also had no stable distinction between saving sensor evidence and
|
||||
saving an operator's layout.
|
||||
|
||||
These are two separate product records:
|
||||
|
||||
1. an **observation session** is evidence from all sources active during one
|
||||
acquisition and must be recorded automatically; and
|
||||
2. a **workspace layout profile** is an operator preference and is saved
|
||||
explicitly.
|
||||
|
||||
Conflating them would allow a UI action to decide whether evidence exists and
|
||||
would make a layout save duplicate or mutate sensor data.
|
||||
|
||||
## Decision
|
||||
|
||||
### Host-owned catalog and private evidence root
|
||||
|
||||
The Control Station owns a private storage root selected by
|
||||
`MISSIONCORE_DATA_DIR`; the repository-local fallback is
|
||||
`.runtime/mission-core/`. The root is outside Git and contains a SQLite catalog
|
||||
plus derived playback artifacts. New source evidence is written to the root
|
||||
selected by `MISSIONCORE_EVIDENCE_DIR`, falling back to
|
||||
`<MISSIONCORE_DATA_DIR>/evidence/sessions`. The current K1 camera gateway still
|
||||
requires that root to remain inside the repository checkout; an external root
|
||||
can retain MQTT evidence but camera archival fails closed. Treat support for an
|
||||
independently attested external camera-evidence root as an open extraction item,
|
||||
not as implemented behavior. SQLite uses foreign keys, WAL journaling and full
|
||||
synchronous durability. Public APIs expose opaque session identifiers and
|
||||
metadata, never filesystem paths.
|
||||
|
||||
Current acquisition writers create only direct children of the private
|
||||
evidence root. Existing repository-level `sessions/*_viewer_live` runs are a
|
||||
legacy import-only source: they are indexed in place, never copied or moved,
|
||||
and are not assigned to a new writer. Their native `.k1mqtt` capture remains
|
||||
the source of record. Import is repeatable and runs in a background reconciler,
|
||||
so completed evidence becomes visible without a restart and HTTP catalog reads
|
||||
never scan the evidence roots. Startup recovery may
|
||||
seal an already incomplete canonical camera epoch under the legacy root; it
|
||||
does not start or append a new acquisition there.
|
||||
|
||||
Before a new session directory is created, the writer atomically creates and
|
||||
locks `<evidence-root>/.current_session`. The marker names the direct child and
|
||||
prevents a second process from writing or replaying the active session. Process
|
||||
exit releases the operating-system lock. On startup, an unlocked stale marker
|
||||
is removed only after no-follow inode revalidation; the interrupted evidence
|
||||
directory is preserved for normal recovery and cataloging.
|
||||
|
||||
If a process exits after raw and metadata creation but before a final summary,
|
||||
the importer accepts only a bounded, structurally valid prefix with aligned
|
||||
metadata and marks the run `interrupted`. It does not invent completion data.
|
||||
When the valid final summary later appears, repeat import promotes the same
|
||||
session to `ready`.
|
||||
|
||||
Native MQTT persistence uses a bounded group commit of at most 0.5 seconds,
|
||||
4 MiB or 32 messages. Raw bytes are fsynced before their aligned metadata rows.
|
||||
The final uncommitted group may be lost, and a raw tail may survive without
|
||||
committed metadata; replay stops at the last validated aligned boundary.
|
||||
|
||||
### Recorded spatial playback
|
||||
|
||||
When a native capture is normally sealed or recovery-sealed, a bounded
|
||||
single-worker reconciler automatically prepares its private derived `.rrd`.
|
||||
Preparation is a backend lifecycle and is never executed by an HTTP replay
|
||||
request. Reopening, seeking, switching tabs or changing display settings reads
|
||||
the already prepared artifact and does not rerun conversion. Export is lossless
|
||||
with respect to every decodable point and pose message in the native capture;
|
||||
it does not pass through the bounded live-preview queue. The RRD uses the
|
||||
recording-local `session_time` duration timeline, while the receive wall clock
|
||||
remains secondary evidence.
|
||||
|
||||
Derived files are published atomically, hashed and accompanied by a cache
|
||||
sidecar bound to the native file identity and digest. A process-owned queue and
|
||||
cross-process file lock prevent overlapping exporters; crash candidates are
|
||||
scavenged before quota accounting. A stale or inconsistent cache is rejected
|
||||
and rebuilt. The browser receives the RRD only through the same-origin opaque
|
||||
session API. It keeps the viewport and timeline hidden until the complete
|
||||
declared recording has been downloaded and decoded, then publishes the scene
|
||||
atomically and starts playback.
|
||||
|
||||
### Camera recording
|
||||
|
||||
Camera acquisition is owned by the device acquisition session, not by a
|
||||
browser window. Preview remains bounded and disposable; archive writes occur
|
||||
before preview fan-out. Closing or refreshing a camera window must not stop the
|
||||
archive while acquisition is active.
|
||||
|
||||
Each codec epoch is stored below the observation session as an initialization
|
||||
segment, independently addressable fMP4 media segments, an append-only JSONL
|
||||
index and an atomic summary. Host arrival epoch and monotonic timestamps are
|
||||
recorded for every segment. The `per-segment-fsync` contract makes `init.mp4`
|
||||
and each complete media segment plus its directory entry durable before its
|
||||
matching index row is committed; the index and interrupted checkpoint are
|
||||
fsynced before append returns. Therefore camera RPO is bounded by the fragment
|
||||
currently being produced, not by an interval or byte batch. Startup recovery
|
||||
retains a contiguous valid prefix, quarantines orphan fragments and marks an
|
||||
unfinalized epoch `interrupted`.
|
||||
|
||||
The background recording-preparation worker also prepares immutable camera
|
||||
descriptors before publishing `ready`. Complete-fragment arrival timestamps are
|
||||
end/availability anchors, so the worker SHA-verifies and parses every fragment,
|
||||
anchors the first decoded sample at `first_arrival - first_duration`, and derives
|
||||
coverage from the exact sum of ISO-BMFF sample durations. It computes finite
|
||||
non-overlapping epoch coverage ends and exact aggregate bytes, then binds both to
|
||||
recorded-media manifest v2's generation digest. Replay v2 additionally requires
|
||||
camera coverage to stay inside the spatial RRD range with a 50 ms tolerance; a
|
||||
future replay v3 will need distinct spatial and session-union ranges.
|
||||
|
||||
The path-free descriptor is atomically persisted in the private derived cache
|
||||
with its checksum and a stat identity covering the native timing origin and
|
||||
every camera summary, index, init and segment file. Restart performs confined
|
||||
stat validation and reuses an unchanged descriptor without reading/hashing media;
|
||||
a missing, corrupt or stale sidecar is rebuilt in the background. An unparseable
|
||||
or ambiguous fragment fails readiness rather than exposing a fake seekable
|
||||
camera. Replay requests consume the prepared descriptor and never repeat media
|
||||
conversion or timing analysis. Launch and manifest aggregate byte counts must
|
||||
agree before the browser admits camera payload downloads.
|
||||
|
||||
The derived RRD writes an actual zero-time anchor at the internal
|
||||
`/__mission_core/session_origin` entity. Keeping it outside `/world` prevents a
|
||||
synthetic visualization layer while ensuring that the decoded RRD timeline,
|
||||
not merely its summary document, begins at session time zero. This changes the
|
||||
derived payload contract: cache v6 rejects v4/v5 sidecars and performs one
|
||||
background rebuild instead of silently reusing an archive without that row.
|
||||
|
||||
Replay v2 exposes two paths to the same pinned generation. `source_url` retains
|
||||
the strict `If-Match: "sha256:…"` contract for Mission Core clients;
|
||||
`viewer_source_url` appends the exact lowercase SHA-256 as a `generation` query
|
||||
for Rerun's native URL loader, which cannot attach custom headers. The server
|
||||
accepts either precondition, rejects a stale query with `412`, and keeps the
|
||||
matching file pinned for the entire immutable/no-transform response lifecycle.
|
||||
|
||||
This is host-arrival synchronization, not proven sensor-clock synchronization.
|
||||
It is sufficient to correlate current K1 point/pose and preview evidence at the
|
||||
known clock boundary, but must not be described as frame-accurate optical/LiDAR
|
||||
alignment.
|
||||
|
||||
Historical runs made before this ADR contain no camera archive. The fact that a
|
||||
camera was visible in the old browser preview does not make video recoverable;
|
||||
the catalog must not manufacture a camera modality for those sessions.
|
||||
|
||||
### Workspace layout
|
||||
|
||||
The first disk action in the spatial workspace saves only the versioned
|
||||
`observation.spatial` layout document:
|
||||
|
||||
- scene display settings;
|
||||
- open tool windows and their z-order;
|
||||
- visible dynamic source identifiers;
|
||||
- active floating source;
|
||||
- source-window rectangles normalized to the observed viewport.
|
||||
|
||||
The document uses optimistic revision control (`ETag` and `If-Match`) and is
|
||||
restored automatically on the next opening. Unknown source identifiers remain
|
||||
desired state so a temporarily disconnected camera can recover its saved
|
||||
window when that source returns. The save action never starts, completes or
|
||||
modifies an observation session.
|
||||
|
||||
### API boundary
|
||||
|
||||
The stable host routes are:
|
||||
|
||||
- `GET /api/v1/observation-sessions` for the recent catalog;
|
||||
- `GET /api/v1/observation-sessions/{id}` for modality/artifact metadata;
|
||||
- `POST /api/v1/observation-sessions/{id}/replay` for a ready launch or a
|
||||
preparation handle;
|
||||
- `GET /api/v1/observation-sessions/{id}/recording-preparation` for exact-job
|
||||
status polling;
|
||||
- `GET /api/v1/observation-sessions/{id}/recording.rrd` for seekable spatial data;
|
||||
- opaque manifest/init/segment routes below
|
||||
`/api/v1/observation-sessions/{id}/media/{artifact}` for recorded cameras;
|
||||
- `GET|PUT /api/v1/workspace-layouts/observation.spatial` for the layout profile.
|
||||
|
||||
The session API precedes the static frontend mount. Recording paths and storage
|
||||
roots are server implementation details.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A completed or interrupted spatial run is discovered and prepared
|
||||
automatically, then can be replayed after browser and service restarts.
|
||||
- A service crash can lose the final uncommitted MQTT group and the camera
|
||||
fragment currently being produced; it cannot make an index point beyond
|
||||
durable camera media or replay a raw tail beyond aligned MQTT metadata.
|
||||
- Raw K1 evidence remains authoritative and derived RRD files can be discarded
|
||||
and regenerated.
|
||||
- Browser performance no longer controls whether camera evidence is retained.
|
||||
- Layout restoration is portable across viewport sizes and dynamic device
|
||||
source catalogs.
|
||||
- Replay builds a separate recorded-source catalog, so an active device camera
|
||||
can never be mixed into the evidence of a saved session. Archived fMP4 codec
|
||||
epochs follow Rerun play/pause/seek through host-arrival best-effort offsets.
|
||||
- A preparation ETag binds polling and cancellation to one exact job; a short
|
||||
launch reservation prevents cache eviction between the launch response and
|
||||
the viewer's first RRD request. Browser session switching never cancels the
|
||||
shared backend preparation.
|
||||
- Storage capacity, retention, quota, encryption-at-rest and export policy are
|
||||
still deployment responsibilities; this ADR does not silently delete source
|
||||
evidence.
|
||||
- Recorded camera playback is confined to canonical archives with a supported
|
||||
fMP4 codec declaration and provable sample duration. Missing historical media,
|
||||
unsupported codec epochs and ambiguous timing fail closed; none is substituted
|
||||
with a live preview.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Use browser cache or IndexedDB for sessions.** This loses the evidence when
|
||||
the browser profile is cleared and cannot guarantee acquisition durability.
|
||||
- **Save a session only when the operator presses the disk icon.** This makes
|
||||
safety evidence optional and fails on crashes or forgotten clicks.
|
||||
- **Store only RRD.** This discards the native evidence needed to review future
|
||||
decoders and protocol assumptions.
|
||||
- **Keep camera acquisition coupled to WebSocket viewers.** A UI disconnect
|
||||
would stop recording and recreate the observed data-loss bug.
|
||||
- **Copy every legacy run into the private catalog root.** This doubles large
|
||||
artifacts without improving integrity; confined zero-copy indexing is
|
||||
sufficient for the current laboratory store.
|
||||
|
|
@ -24,13 +24,16 @@ The plugin owns:
|
|||
- firmware-scoped protobuf/LZ4 and legacy codecs;
|
||||
- normalization of K1 point cloud and pose, plus raw-only preservation of the
|
||||
still-undecoded status and heartbeat channels;
|
||||
- exact-profile left/right RTSP producer selection, H.264 copy-remux preview and
|
||||
the handoff of acquisition-owned init/fMP4/index evidence to the host archive;
|
||||
- K1-specific operator instructions and compatibility tests.
|
||||
|
||||
The plugin does not own:
|
||||
|
||||
- Mission Core navigation, fleet, missions, users, roles, or audit;
|
||||
- the generic spatial scene or Rerun Web Viewer;
|
||||
- platform storage, remote transport, or other devices.
|
||||
- the host observation catalog, RRD preparation/cache, recorded-camera player,
|
||||
workspace layout, platform storage, remote transport, or other devices.
|
||||
|
||||
Until extraction is complete, `src/k1link` is the compatibility source of
|
||||
truth and every move into this package must preserve replay and real-device
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ MAX_TOPIC_BYTES = 65_535
|
|||
CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
KEEPALIVE_SECONDS = 30
|
||||
LOOP_INTERVAL_SECONDS = 0.25
|
||||
GROUP_COMMIT_INTERVAL_SECONDS = 0.5
|
||||
GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024
|
||||
GROUP_COMMIT_MAX_MESSAGES = 32
|
||||
|
||||
# Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes.
|
||||
RAW_MAGIC = b"K1MQTT\x00\x01"
|
||||
|
|
@ -166,6 +169,9 @@ class _CaptureWriter:
|
|||
self.topic_counts: dict[str, int] = {}
|
||||
self._raw: IO[bytes] | None = None
|
||||
self._metadata: IO[str] | None = None
|
||||
self._pending_metadata: list[str] = []
|
||||
self._pending_raw_bytes = 0
|
||||
self._last_commit_monotonic = time.monotonic()
|
||||
|
||||
def open(self) -> None:
|
||||
self.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -179,6 +185,7 @@ class _CaptureWriter:
|
|||
self._raw = _open_binary_exclusive(self.raw_path)
|
||||
self._raw.write(RAW_MAGIC)
|
||||
self._metadata = _open_text_exclusive(self.metadata_path)
|
||||
_fsync_directory(self.out_dir)
|
||||
except BaseException:
|
||||
with suppress(OSError):
|
||||
self.close()
|
||||
|
|
@ -212,7 +219,9 @@ class _CaptureWriter:
|
|||
"max_message_bytes": self.max_message_bytes,
|
||||
"reason": "message_too_large",
|
||||
}
|
||||
self._commit_pending()
|
||||
self._write_metadata(metadata, record)
|
||||
os.fsync(metadata.fileno())
|
||||
raise MessageTooLargeError(
|
||||
f"message on {topic!r} is {len(payload)} bytes; "
|
||||
f"limit is {self.max_message_bytes} bytes"
|
||||
|
|
@ -246,7 +255,17 @@ class _CaptureWriter:
|
|||
"raw_payload_offset": offset + len(header) + len(topic_bytes),
|
||||
"raw_frame_bytes": frame_bytes,
|
||||
}
|
||||
self._write_metadata(metadata, record)
|
||||
self._pending_metadata.append(
|
||||
json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
)
|
||||
self._pending_raw_bytes += frame_bytes
|
||||
if (
|
||||
len(self._pending_metadata) >= GROUP_COMMIT_MAX_MESSAGES
|
||||
or self._pending_raw_bytes >= GROUP_COMMIT_MAX_BYTES
|
||||
or time.monotonic() - self._last_commit_monotonic
|
||||
>= GROUP_COMMIT_INTERVAL_SECONDS
|
||||
):
|
||||
self._commit_pending()
|
||||
return CapturedMqttMessage(
|
||||
sequence=self.message_count,
|
||||
topic=topic,
|
||||
|
|
@ -261,7 +280,12 @@ class _CaptureWriter:
|
|||
|
||||
def close(self) -> None:
|
||||
first_error: OSError | None = None
|
||||
# Make raw frames durable before making their JSONL references durable.
|
||||
try:
|
||||
self._commit_pending()
|
||||
except OSError as exc:
|
||||
first_error = exc
|
||||
# Make the magic (including the zero-message case) and any last stream
|
||||
# buffers durable before the handles are released.
|
||||
for stream in (self._raw, self._metadata):
|
||||
if stream is None or stream.closed:
|
||||
continue
|
||||
|
|
@ -280,6 +304,13 @@ class _CaptureWriter:
|
|||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
def maybe_commit(self, now_monotonic: float | None = None) -> None:
|
||||
if not self._pending_metadata:
|
||||
return
|
||||
now = time.monotonic() if now_monotonic is None else now_monotonic
|
||||
if now - self._last_commit_monotonic >= GROUP_COMMIT_INTERVAL_SECONDS:
|
||||
self._commit_pending(now_monotonic=now)
|
||||
|
||||
@property
|
||||
def raw_bytes(self) -> int:
|
||||
if self.raw_path.exists():
|
||||
|
|
@ -301,6 +332,26 @@ class _CaptureWriter:
|
|||
stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
stream.flush()
|
||||
|
||||
def _commit_pending(self, *, now_monotonic: float | None = None) -> None:
|
||||
if not self._pending_metadata:
|
||||
return
|
||||
raw = self._require_raw()
|
||||
metadata = self._require_metadata()
|
||||
# Group commit invariant: durable raw bytes always precede durable
|
||||
# metadata references. A crash can therefore lose only the bounded
|
||||
# in-memory group, never expose metadata pointing past durable raw.
|
||||
raw.flush()
|
||||
os.fsync(raw.fileno())
|
||||
payload = "".join(self._pending_metadata)
|
||||
self._pending_metadata.clear()
|
||||
self._pending_raw_bytes = 0
|
||||
metadata.write(payload)
|
||||
metadata.flush()
|
||||
os.fsync(metadata.fileno())
|
||||
self._last_commit_monotonic = (
|
||||
time.monotonic() if now_monotonic is None else now_monotonic
|
||||
)
|
||||
|
||||
|
||||
def validate_private_ipv4(value: str) -> str:
|
||||
"""Require a literal RFC1918 address so capture cannot target arbitrary hosts."""
|
||||
|
|
@ -536,6 +587,11 @@ def capture_mqtt(
|
|||
break
|
||||
|
||||
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
|
||||
try:
|
||||
writer.maybe_commit()
|
||||
except OSError as exc:
|
||||
fail("capture_error", f"group commit failed: {type(exc).__name__}: {exc}")
|
||||
break
|
||||
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
|
||||
fail(
|
||||
"connection_lost",
|
||||
|
|
@ -681,3 +737,12 @@ def _write_summary_exclusive(path: Path, summary: CaptureSummary) -> None:
|
|||
stream.write(serialized)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
_fsync_directory(path.parent)
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
"""Host-owned observation session catalog and durable layout persistence."""
|
||||
|
||||
from .active import (
|
||||
ActiveSessionLease,
|
||||
ActiveSessionLeaseError,
|
||||
recover_stale_active_session_marker,
|
||||
)
|
||||
from .media import (
|
||||
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||
RecordedMediaFile,
|
||||
RecordedMediaInspector,
|
||||
RecordedMediaManifest,
|
||||
validate_recorded_media_timeline,
|
||||
)
|
||||
from .models import (
|
||||
LayoutConflictError,
|
||||
RecordedMediaArtifact,
|
||||
ReplayCommand,
|
||||
SessionIntegrityError,
|
||||
SessionNotFoundError,
|
||||
SessionNotReplayableError,
|
||||
)
|
||||
from .preparation import (
|
||||
RecordingPreparationQueueFull,
|
||||
RecordingPreparationSnapshot,
|
||||
SessionRecordingPreparationManager,
|
||||
)
|
||||
from .recording import (
|
||||
MaterializedRecording,
|
||||
RecordingMaterializationCancelled,
|
||||
RecordingMaterializationError,
|
||||
SessionRecordingMaterializer,
|
||||
)
|
||||
from .store import (
|
||||
SessionStore,
|
||||
resolve_missioncore_data_dir,
|
||||
resolve_missioncore_evidence_dir,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LayoutConflictError",
|
||||
"ActiveSessionLease",
|
||||
"ActiveSessionLeaseError",
|
||||
"MaterializedRecording",
|
||||
"RecordingMaterializationCancelled",
|
||||
"RecordedMediaArtifact",
|
||||
"RECORDED_MEDIA_MANIFEST_SCHEMA",
|
||||
"RecordedMediaFile",
|
||||
"RecordedMediaInspector",
|
||||
"RecordedMediaManifest",
|
||||
"ReplayCommand",
|
||||
"RecordingMaterializationError",
|
||||
"RecordingPreparationQueueFull",
|
||||
"RecordingPreparationSnapshot",
|
||||
"SessionIntegrityError",
|
||||
"SessionNotFoundError",
|
||||
"SessionNotReplayableError",
|
||||
"SessionRecordingMaterializer",
|
||||
"SessionRecordingPreparationManager",
|
||||
"SessionStore",
|
||||
"recover_stale_active_session_marker",
|
||||
"resolve_missioncore_data_dir",
|
||||
"resolve_missioncore_evidence_dir",
|
||||
"validate_recorded_media_timeline",
|
||||
]
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
ACTIVE_SESSION_MARKER = ".current_session"
|
||||
|
||||
|
||||
class ActiveSessionLeaseError(RuntimeError):
|
||||
"""The evidence root already has an active writer or cannot be leased."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ActiveSessionLease:
|
||||
"""Cross-process lease that keeps an in-progress session out of replay.
|
||||
|
||||
The marker is intentionally written before the session directory is
|
||||
created. Discovery therefore observes either no candidate yet or a
|
||||
candidate protected by an already locked marker. A process crash releases
|
||||
the OS lock; startup recovery can then remove the stale marker while
|
||||
preserving the interrupted evidence directory.
|
||||
"""
|
||||
|
||||
sessions_root: Path
|
||||
session_root: Path
|
||||
_descriptor: int
|
||||
_marker_identity: tuple[int, int]
|
||||
_released: bool = False
|
||||
|
||||
@classmethod
|
||||
def acquire(cls, sessions_root: Path, session_root: Path) -> ActiveSessionLease:
|
||||
root = sessions_root.expanduser().resolve()
|
||||
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
target = session_root.expanduser().absolute()
|
||||
if target.parent.resolve() != root or not target.name:
|
||||
raise ActiveSessionLeaseError("active session must be a direct child of evidence root")
|
||||
|
||||
marker = root / ACTIVE_SESSION_MARKER
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(marker, flags, 0o600)
|
||||
except FileExistsError as exc:
|
||||
raise ActiveSessionLeaseError(
|
||||
"another observation session owns the evidence root"
|
||||
) from exc
|
||||
try:
|
||||
payload = f"{target.name}\n".encode()
|
||||
os.write(descriptor, payload)
|
||||
os.fsync(descriptor)
|
||||
_lock_descriptor(descriptor, blocking=False)
|
||||
_fsync_directory(root)
|
||||
metadata = os.fstat(descriptor)
|
||||
return cls(
|
||||
sessions_root=root,
|
||||
session_root=target,
|
||||
_descriptor=descriptor,
|
||||
_marker_identity=(metadata.st_dev, metadata.st_ino),
|
||||
)
|
||||
except BaseException:
|
||||
try:
|
||||
marker.unlink(missing_ok=True)
|
||||
_fsync_directory(root)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
def release(self) -> None:
|
||||
if self._released:
|
||||
return
|
||||
marker = self.sessions_root / ACTIVE_SESSION_MARKER
|
||||
try:
|
||||
try:
|
||||
metadata = marker.lstat()
|
||||
except FileNotFoundError:
|
||||
metadata = None
|
||||
if metadata is not None and (metadata.st_dev, metadata.st_ino) == self._marker_identity:
|
||||
marker.unlink()
|
||||
_fsync_directory(self.sessions_root)
|
||||
finally:
|
||||
_unlock_descriptor(self._descriptor)
|
||||
os.close(self._descriptor)
|
||||
self._released = True
|
||||
|
||||
def __enter__(self) -> ActiveSessionLease:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.release()
|
||||
|
||||
|
||||
def recover_stale_active_session_marker(sessions_root: Path) -> bool:
|
||||
"""Remove only an unlocked marker left by a terminated writer."""
|
||||
|
||||
root = sessions_root.expanduser().resolve()
|
||||
marker = root / ACTIVE_SESSION_MARKER
|
||||
try:
|
||||
marker_stat = marker.lstat()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
|
||||
return False
|
||||
try:
|
||||
descriptor = os.open(marker, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0))
|
||||
except OSError:
|
||||
return False
|
||||
locked = False
|
||||
try:
|
||||
try:
|
||||
_lock_descriptor(descriptor, blocking=False)
|
||||
locked = True
|
||||
except OSError:
|
||||
return False
|
||||
opened = os.fstat(descriptor)
|
||||
try:
|
||||
current = marker.lstat()
|
||||
except OSError:
|
||||
return False
|
||||
if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
|
||||
return False
|
||||
marker.unlink()
|
||||
_fsync_directory(root)
|
||||
return True
|
||||
finally:
|
||||
if locked:
|
||||
_unlock_descriptor(descriptor)
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _lock_descriptor(descriptor: int, *, blocking: bool) -> None:
|
||||
if os.name == "nt":
|
||||
msvcrt = cast(Any, importlib.import_module("msvcrt"))
|
||||
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
|
||||
msvcrt.locking(descriptor, mode, 1)
|
||||
return
|
||||
import fcntl
|
||||
|
||||
operation = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
|
||||
fcntl.flock(descriptor, operation)
|
||||
|
||||
|
||||
def _unlock_descriptor(descriptor: int) -> None:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
msvcrt = cast(Any, importlib.import_module("msvcrt"))
|
||||
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
|
||||
return
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
|
@ -0,0 +1,818 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import IO, Any
|
||||
|
||||
from k1link.mqtt.capture import (
|
||||
FRAME_HEADER,
|
||||
GROUP_COMMIT_MAX_BYTES,
|
||||
GROUP_COMMIT_MAX_MESSAGES,
|
||||
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||
MAX_TOPIC_BYTES,
|
||||
RAW_MAGIC,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
LegacyMediaSourceCandidate,
|
||||
LegacySessionCandidate,
|
||||
SessionModality,
|
||||
SessionStatus,
|
||||
)
|
||||
|
||||
MAX_LEGACY_JSON_BYTES = 2 * 1024 * 1024
|
||||
LEGACY_SESSION_PATTERN = re.compile(r"^[A-Za-z0-9._-]+_viewer_live(?:_[0-9]+)?$")
|
||||
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
|
||||
MEDIA_SOURCE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
MEDIA_EPOCH_PATTERN = re.compile(r"^epoch-(?!0+$)[0-9]+$")
|
||||
MEDIA_SEGMENT_PATTERN = re.compile(r"^[0-9]+\.m4s$")
|
||||
MAX_MEDIA_INDEX_BYTES = 32 * 1024 * 1024
|
||||
MAX_RECOVERY_METADATA_BYTES = 64 * 1024 * 1024
|
||||
MAX_RECOVERY_METADATA_LINE_BYTES = 64 * 1024
|
||||
MAX_RECOVERY_MESSAGES = 500_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RecoveredCapture:
|
||||
message_count: int
|
||||
topic_counts: dict[str, int]
|
||||
started_at_utc: str
|
||||
completed_at_utc: str
|
||||
duration_seconds: float
|
||||
raw_committed_bytes: int
|
||||
metadata_committed_bytes: int
|
||||
|
||||
|
||||
def discover_legacy_viewer_sessions(root: Path) -> tuple[LegacySessionCandidate, ...]:
|
||||
"""Describe legacy live sessions without copying or decoding their payloads."""
|
||||
|
||||
allowed_root = root.expanduser().resolve()
|
||||
if not allowed_root.is_dir():
|
||||
return ()
|
||||
active_session_root = _active_session_root(allowed_root)
|
||||
candidates: list[LegacySessionCandidate] = []
|
||||
for entry in sorted(allowed_root.iterdir()):
|
||||
if not entry.is_dir() or not LEGACY_SESSION_PATTERN.fullmatch(entry.name):
|
||||
continue
|
||||
resolved = entry.resolve()
|
||||
if not resolved.is_relative_to(allowed_root):
|
||||
continue
|
||||
# The saved-session catalog deliberately omits the writer-owned run.
|
||||
# Besides avoiding a misleading interrupted/error row, this prevents
|
||||
# the two-second reconciler from repeatedly scanning and hashing a
|
||||
# capture that is still growing. Marker release makes it discoverable
|
||||
# on the next reconciliation pass.
|
||||
if active_session_root == resolved:
|
||||
continue
|
||||
candidates.append(
|
||||
_describe_session(
|
||||
allowed_root,
|
||||
resolved,
|
||||
active=False,
|
||||
)
|
||||
)
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _describe_session(
|
||||
allowed_root: Path,
|
||||
session_root: Path,
|
||||
*,
|
||||
active: bool,
|
||||
) -> LegacySessionCandidate:
|
||||
capture_root = session_root / "captures" / "mqtt_live"
|
||||
raw_path = capture_root / "mqtt.raw.k1mqtt"
|
||||
summary = _read_json_object(capture_root / "mqtt.summary.json")
|
||||
manifest = _read_json_object(session_root / "manifest.redacted.json")
|
||||
raw_bytes = raw_path.stat().st_size if _confined_file(raw_path, session_root) else 0
|
||||
raw_magic_ok = False
|
||||
if raw_bytes >= len(RAW_MAGIC):
|
||||
with raw_path.open("rb") as stream:
|
||||
raw_magic_ok = stream.read(len(RAW_MAGIC)) == RAW_MAGIC
|
||||
|
||||
has_completed_summary = _is_completed_summary(summary)
|
||||
completed = (
|
||||
_validate_completed_capture(capture_root, session_root, raw_path, summary)
|
||||
if has_completed_summary and raw_magic_ok
|
||||
else None
|
||||
)
|
||||
recovered = (
|
||||
_recover_interrupted_capture(capture_root, session_root, raw_path)
|
||||
if raw_magic_ok
|
||||
and completed is None
|
||||
and (not has_completed_summary or summary.get("error") is not None)
|
||||
else None
|
||||
)
|
||||
message_count = (
|
||||
_non_negative_int(summary.get("message_count"))
|
||||
if completed is not None
|
||||
else recovered.message_count if recovered is not None else 0
|
||||
)
|
||||
summary_topic_counts = summary.get("topic_counts")
|
||||
topic_counts = (
|
||||
_normalized_topic_counts(summary_topic_counts)
|
||||
if completed is not None
|
||||
else recovered.topic_counts if recovered is not None else {}
|
||||
)
|
||||
media_sources = _discover_media_sources(session_root)
|
||||
modalities = list(_modalities(topic_counts))
|
||||
if media_sources:
|
||||
modalities.append("video")
|
||||
replayable = bool(
|
||||
not active
|
||||
and
|
||||
raw_magic_ok
|
||||
and raw_bytes > len(RAW_MAGIC)
|
||||
and message_count > 0
|
||||
and any(modality in {"point-cloud", "trajectory"} for modality in modalities)
|
||||
)
|
||||
if completed is not None:
|
||||
status = (
|
||||
"interrupted"
|
||||
if active
|
||||
else _status(replayable, summary.get("error"), summary.get("stop_reason"))
|
||||
)
|
||||
started_at = (
|
||||
_safe_timestamp(summary.get("created_at_utc"))
|
||||
or _safe_timestamp(manifest.get("started_at_utc"))
|
||||
or _timestamp_from_session_name(session_root.name)
|
||||
)
|
||||
completed_at = _safe_timestamp(summary.get("completed_at_utc")) or _safe_timestamp(
|
||||
manifest.get("completed_at_utc")
|
||||
)
|
||||
duration = _duration(summary.get("capture_elapsed_seconds"))
|
||||
declared_hash = _declared_raw_hash(summary)
|
||||
integrity_status = "verified" if declared_hash is not None else "validated-structure"
|
||||
else:
|
||||
status = "interrupted" if recovered is not None or active else "failed"
|
||||
started_at = (
|
||||
recovered.started_at_utc
|
||||
if recovered is not None
|
||||
else _safe_timestamp(manifest.get("started_at_utc"))
|
||||
or _timestamp_from_session_name(session_root.name)
|
||||
)
|
||||
completed_at = recovered.completed_at_utc if recovered is not None else None
|
||||
duration = recovered.duration_seconds if recovered is not None else None
|
||||
declared_hash = None
|
||||
integrity_status = "validated-prefix" if recovered is not None else "unverified"
|
||||
|
||||
replay_raw_bytes = (
|
||||
completed.raw_committed_bytes
|
||||
if completed is not None
|
||||
else recovered.raw_committed_bytes if recovered is not None else 0
|
||||
)
|
||||
replay_metadata_bytes = (
|
||||
completed.metadata_committed_bytes
|
||||
if completed is not None
|
||||
else recovered.metadata_committed_bytes if recovered is not None else 0
|
||||
)
|
||||
|
||||
return LegacySessionCandidate(
|
||||
session_id=session_root.name,
|
||||
display_name=session_root.name,
|
||||
status=status,
|
||||
started_at_utc=started_at,
|
||||
completed_at_utc=completed_at,
|
||||
duration_seconds=duration,
|
||||
modalities=tuple(modalities),
|
||||
replayable=replayable,
|
||||
total_bytes=raw_bytes + sum(source.byte_length for source in media_sources),
|
||||
allowed_root=allowed_root,
|
||||
session_root=session_root,
|
||||
raw_path=raw_path.resolve(strict=False),
|
||||
raw_byte_length=raw_bytes,
|
||||
replay_raw_byte_length=replay_raw_bytes,
|
||||
replay_metadata_byte_length=replay_metadata_bytes,
|
||||
raw_sha256=declared_hash,
|
||||
raw_integrity_status=integrity_status,
|
||||
media_sources=media_sources,
|
||||
)
|
||||
|
||||
|
||||
def _is_completed_summary(summary: dict[str, Any]) -> bool:
|
||||
return (
|
||||
"message_count" in summary
|
||||
and isinstance(summary.get("message_count"), int)
|
||||
and not isinstance(summary.get("message_count"), bool)
|
||||
and isinstance(summary.get("topic_counts"), dict)
|
||||
and isinstance(summary.get("stop_reason"), str)
|
||||
)
|
||||
|
||||
|
||||
def _validate_completed_capture(
|
||||
capture_root: Path,
|
||||
session_root: Path,
|
||||
raw_path: Path,
|
||||
summary: dict[str, Any],
|
||||
) -> _RecoveredCapture | None:
|
||||
metadata_path = capture_root / "mqtt.metadata.jsonl"
|
||||
try:
|
||||
raw_stat = raw_path.lstat()
|
||||
metadata_stat = metadata_path.lstat()
|
||||
except OSError:
|
||||
return None
|
||||
if not stat.S_ISREG(raw_stat.st_mode) or not stat.S_ISREG(metadata_stat.st_mode):
|
||||
return None
|
||||
fingerprint = json.dumps(
|
||||
{
|
||||
"message_count": summary.get("message_count"),
|
||||
"topic_counts": summary.get("topic_counts"),
|
||||
"raw_bytes": summary.get("raw_bytes"),
|
||||
"artifact_hashes": summary.get("artifact_hashes"),
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return _validate_completed_capture_cached(
|
||||
str(capture_root),
|
||||
str(session_root),
|
||||
str(raw_path),
|
||||
_stat_identity(raw_stat),
|
||||
_stat_identity(metadata_stat),
|
||||
fingerprint,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _validate_completed_capture_cached(
|
||||
capture_root_text: str,
|
||||
session_root_text: str,
|
||||
raw_path_text: str,
|
||||
_raw_identity: tuple[int, int, int, int, int],
|
||||
_metadata_identity: tuple[int, int, int, int, int],
|
||||
summary_fingerprint: str,
|
||||
) -> _RecoveredCapture | None:
|
||||
capture_root = Path(capture_root_text)
|
||||
session_root = Path(session_root_text)
|
||||
raw_path = Path(raw_path_text)
|
||||
summary = json.loads(summary_fingerprint)
|
||||
validated = _scan_capture_prefix(
|
||||
capture_root,
|
||||
session_root,
|
||||
raw_path,
|
||||
tolerate_incomplete_metadata_tail=False,
|
||||
tolerate_raw_crash_tail=False,
|
||||
)
|
||||
if validated is None:
|
||||
return None
|
||||
if validated.message_count != _non_negative_int(summary.get("message_count")):
|
||||
return None
|
||||
if validated.topic_counts != _normalized_topic_counts(summary.get("topic_counts")):
|
||||
return None
|
||||
declared_raw_bytes = summary.get("raw_bytes")
|
||||
if declared_raw_bytes is not None and (
|
||||
not isinstance(declared_raw_bytes, int)
|
||||
or isinstance(declared_raw_bytes, bool)
|
||||
or declared_raw_bytes != validated.raw_committed_bytes
|
||||
):
|
||||
return None
|
||||
raw_hash = _declared_raw_hash(summary)
|
||||
if raw_hash is not None and _sha256_stable(raw_path) != raw_hash:
|
||||
return None
|
||||
metadata_hash = _declared_metadata_hash(summary)
|
||||
metadata_path = capture_root / "mqtt.metadata.jsonl"
|
||||
if metadata_hash is not None and _sha256_stable(metadata_path) != metadata_hash:
|
||||
return None
|
||||
return validated
|
||||
|
||||
|
||||
def _recover_interrupted_capture(
|
||||
capture_root: Path,
|
||||
session_root: Path,
|
||||
raw_path: Path,
|
||||
) -> _RecoveredCapture | None:
|
||||
return _scan_capture_prefix(
|
||||
capture_root,
|
||||
session_root,
|
||||
raw_path,
|
||||
tolerate_incomplete_metadata_tail=True,
|
||||
tolerate_raw_crash_tail=True,
|
||||
)
|
||||
|
||||
|
||||
def _scan_capture_prefix(
|
||||
capture_root: Path,
|
||||
session_root: Path,
|
||||
raw_path: Path,
|
||||
*,
|
||||
tolerate_incomplete_metadata_tail: bool,
|
||||
tolerate_raw_crash_tail: bool,
|
||||
) -> _RecoveredCapture | None:
|
||||
metadata_path = capture_root / "mqtt.metadata.jsonl"
|
||||
if not _confined_file(raw_path, session_root) or not _confined_file(
|
||||
metadata_path, session_root
|
||||
):
|
||||
return None
|
||||
try:
|
||||
raw_size = raw_path.stat().st_size
|
||||
with raw_path.open("rb") as raw_stream, metadata_path.open("rb") as metadata_stream:
|
||||
if raw_stream.read(len(RAW_MAGIC)) != RAW_MAGIC:
|
||||
return None
|
||||
consumed_metadata_bytes = 0
|
||||
message_count = 0
|
||||
topic_counts: dict[str, int] = {}
|
||||
first_epoch_ns: int | None = None
|
||||
last_epoch_ns: int | None = None
|
||||
first_monotonic_ns: int | None = None
|
||||
last_monotonic_ns: int | None = None
|
||||
first_timestamp: str | None = None
|
||||
last_timestamp: str | None = None
|
||||
metadata_committed_bytes = 0
|
||||
while (
|
||||
consumed_metadata_bytes < MAX_RECOVERY_METADATA_BYTES
|
||||
and message_count < MAX_RECOVERY_MESSAGES
|
||||
):
|
||||
remaining = MAX_RECOVERY_METADATA_BYTES - consumed_metadata_bytes
|
||||
read_limit = min(MAX_RECOVERY_METADATA_LINE_BYTES + 1, remaining + 1)
|
||||
line = metadata_stream.readline(read_limit)
|
||||
if not line:
|
||||
break
|
||||
consumed_metadata_bytes += len(line)
|
||||
if (
|
||||
len(line) > MAX_RECOVERY_METADATA_LINE_BYTES
|
||||
or consumed_metadata_bytes > MAX_RECOVERY_METADATA_BYTES
|
||||
):
|
||||
return None
|
||||
if not line.endswith(b"\n"):
|
||||
if tolerate_incomplete_metadata_tail:
|
||||
break
|
||||
return None
|
||||
record = _metadata_record(line)
|
||||
if record is None:
|
||||
return None
|
||||
metadata_committed_bytes = consumed_metadata_bytes
|
||||
if record.get("record_type") != "message":
|
||||
continue
|
||||
expected_sequence = message_count + 1
|
||||
validated = _validate_recovery_frame(
|
||||
raw_stream,
|
||||
raw_size=raw_size,
|
||||
record=record,
|
||||
expected_sequence=expected_sequence,
|
||||
)
|
||||
if validated is None:
|
||||
return None
|
||||
topic, epoch_ns, monotonic_ns, timestamp = validated
|
||||
if last_monotonic_ns is not None and monotonic_ns < last_monotonic_ns:
|
||||
return None
|
||||
message_count = expected_sequence
|
||||
topic_counts[topic] = topic_counts.get(topic, 0) + 1
|
||||
if first_epoch_ns is None:
|
||||
first_epoch_ns = epoch_ns
|
||||
first_monotonic_ns = monotonic_ns
|
||||
first_timestamp = timestamp
|
||||
last_epoch_ns = epoch_ns
|
||||
last_monotonic_ns = monotonic_ns
|
||||
last_timestamp = timestamp
|
||||
if metadata_stream.read(1):
|
||||
return None
|
||||
raw_committed_bytes = raw_stream.tell()
|
||||
if raw_committed_bytes != raw_size and not (
|
||||
tolerate_raw_crash_tail
|
||||
and _tolerable_raw_crash_tail(raw_stream, raw_size=raw_size)
|
||||
):
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
if (
|
||||
message_count == 0
|
||||
or first_epoch_ns is None
|
||||
or last_epoch_ns is None
|
||||
or first_timestamp is None
|
||||
or last_timestamp is None
|
||||
or first_monotonic_ns is None
|
||||
or last_monotonic_ns is None
|
||||
):
|
||||
return None
|
||||
return _RecoveredCapture(
|
||||
message_count=message_count,
|
||||
topic_counts=topic_counts,
|
||||
started_at_utc=first_timestamp,
|
||||
completed_at_utc=last_timestamp,
|
||||
duration_seconds=(last_monotonic_ns - first_monotonic_ns) / 1_000_000_000,
|
||||
raw_committed_bytes=raw_committed_bytes,
|
||||
metadata_committed_bytes=metadata_committed_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _metadata_record(line: bytes) -> dict[str, Any] | None:
|
||||
try:
|
||||
value = json.loads(line.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _tolerable_raw_crash_tail(raw_stream: IO[bytes], *, raw_size: int) -> bool:
|
||||
"""Accept at most one raw frame which was not committed by metadata.
|
||||
|
||||
The capture writer flushes raw before metadata and does not begin the next
|
||||
message until the current metadata record is written. Consequently a
|
||||
process crash can leave only a prefix (or all) of one additional frame.
|
||||
Anything beyond that boundary is corruption, not a recoverable tail.
|
||||
"""
|
||||
|
||||
tail_offset = raw_stream.tell()
|
||||
tail_bytes = raw_size - tail_offset
|
||||
if tail_bytes <= 0:
|
||||
return True
|
||||
if tail_bytes > GROUP_COMMIT_MAX_BYTES + MAX_CONFIGURABLE_MESSAGE_BYTES:
|
||||
return False
|
||||
frames = 0
|
||||
while raw_stream.tell() < raw_size:
|
||||
remaining = raw_size - raw_stream.tell()
|
||||
header = raw_stream.read(min(remaining, FRAME_HEADER.size))
|
||||
if len(header) < FRAME_HEADER.size:
|
||||
return frames < GROUP_COMMIT_MAX_MESSAGES
|
||||
topic_length, payload_length = FRAME_HEADER.unpack(header)
|
||||
if not 1 <= topic_length <= MAX_TOPIC_BYTES:
|
||||
return False
|
||||
if payload_length > MAX_CONFIGURABLE_MESSAGE_BYTES:
|
||||
return False
|
||||
frame_bytes = FRAME_HEADER.size + topic_length + payload_length
|
||||
frame_remaining = raw_size - (raw_stream.tell() - FRAME_HEADER.size)
|
||||
available_topic_bytes = min(
|
||||
topic_length,
|
||||
max(0, frame_remaining - FRAME_HEADER.size),
|
||||
)
|
||||
topic_prefix = raw_stream.read(available_topic_bytes)
|
||||
if available_topic_bytes < topic_length:
|
||||
return frames < GROUP_COMMIT_MAX_MESSAGES
|
||||
try:
|
||||
topic = topic_prefix.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
if not topic:
|
||||
return False
|
||||
payload_available = frame_remaining - FRAME_HEADER.size - topic_length
|
||||
if payload_available < payload_length:
|
||||
return frames < GROUP_COMMIT_MAX_MESSAGES
|
||||
raw_stream.seek(payload_length, 1)
|
||||
frames += 1
|
||||
if frames > GROUP_COMMIT_MAX_MESSAGES or frame_remaining < frame_bytes:
|
||||
return False
|
||||
return frames <= GROUP_COMMIT_MAX_MESSAGES
|
||||
|
||||
|
||||
def _validate_recovery_frame(
|
||||
raw_stream: IO[bytes],
|
||||
*,
|
||||
raw_size: int,
|
||||
record: dict[str, Any],
|
||||
expected_sequence: int,
|
||||
) -> tuple[str, int, int, str] | None:
|
||||
sequence = record.get("sequence")
|
||||
topic = record.get("topic")
|
||||
payload_bytes = record.get("payload_bytes")
|
||||
frame_offset = record.get("raw_frame_offset")
|
||||
payload_offset = record.get("raw_payload_offset")
|
||||
frame_bytes = record.get("raw_frame_bytes")
|
||||
epoch_ns = record.get("received_at_epoch_ns")
|
||||
monotonic_ns = record.get("received_monotonic_ns")
|
||||
if (
|
||||
sequence != expected_sequence
|
||||
or not isinstance(topic, str)
|
||||
or not topic
|
||||
or not isinstance(payload_bytes, int)
|
||||
or isinstance(payload_bytes, bool)
|
||||
or not 0 <= payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES
|
||||
or not isinstance(frame_offset, int)
|
||||
or isinstance(frame_offset, bool)
|
||||
or frame_offset != raw_stream.tell()
|
||||
or not isinstance(payload_offset, int)
|
||||
or isinstance(payload_offset, bool)
|
||||
or not isinstance(frame_bytes, int)
|
||||
or isinstance(frame_bytes, bool)
|
||||
or not isinstance(epoch_ns, int)
|
||||
or isinstance(epoch_ns, bool)
|
||||
or epoch_ns < 0
|
||||
or not isinstance(monotonic_ns, int)
|
||||
or isinstance(monotonic_ns, bool)
|
||||
or monotonic_ns < 0
|
||||
):
|
||||
return None
|
||||
header = raw_stream.read(FRAME_HEADER.size)
|
||||
if len(header) != FRAME_HEADER.size:
|
||||
return None
|
||||
topic_length, raw_payload_bytes = FRAME_HEADER.unpack(header)
|
||||
if (
|
||||
not 1 <= topic_length <= MAX_TOPIC_BYTES
|
||||
or raw_payload_bytes != payload_bytes
|
||||
or payload_offset != frame_offset + FRAME_HEADER.size + topic_length
|
||||
or frame_bytes != FRAME_HEADER.size + topic_length + payload_bytes
|
||||
or frame_offset + frame_bytes > raw_size
|
||||
):
|
||||
return None
|
||||
raw_topic = raw_stream.read(topic_length)
|
||||
try:
|
||||
decoded_topic = raw_topic.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
if decoded_topic != topic:
|
||||
return None
|
||||
raw_stream.seek(payload_bytes, 1)
|
||||
timestamp = _safe_timestamp(record.get("received_at_utc"))
|
||||
if timestamp is None:
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(epoch_ns / 1_000_000_000, tz=UTC).isoformat()
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return None
|
||||
return topic, epoch_ns, monotonic_ns, timestamp
|
||||
|
||||
|
||||
def _read_json_object(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
if not path.is_file() or path.stat().st_size > MAX_LEGACY_JSON_BYTES:
|
||||
return {}
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _confined_file(path: Path, session_root: Path) -> bool:
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError:
|
||||
return False
|
||||
return resolved.is_file() and resolved.is_relative_to(session_root)
|
||||
|
||||
|
||||
def _non_negative_int(value: object) -> int:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
|
||||
|
||||
|
||||
def _normalized_topic_counts(value: object) -> dict[str, int]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
result: dict[str, int] = {}
|
||||
for topic, raw_count in value.items():
|
||||
count = _non_negative_int(raw_count)
|
||||
if isinstance(topic, str) and count > 0:
|
||||
result[topic] = count
|
||||
return result
|
||||
|
||||
|
||||
def _duration(value: object) -> float | None:
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
return None
|
||||
duration = float(value)
|
||||
return duration if math.isfinite(duration) and duration >= 0 else None
|
||||
|
||||
|
||||
def _safe_timestamp(value: object) -> str | None:
|
||||
if not isinstance(value, str) or len(value) > 64:
|
||||
return None
|
||||
candidate = value.strip()
|
||||
try:
|
||||
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _timestamp_from_session_name(session_id: str) -> str | None:
|
||||
prefix = session_id.split("_", 1)[0]
|
||||
try:
|
||||
parsed = datetime.strptime(prefix, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _declared_raw_hash(summary: dict[str, Any]) -> str | None:
|
||||
hashes = summary.get("artifact_hashes")
|
||||
if not isinstance(hashes, dict):
|
||||
return None
|
||||
value = hashes.get("raw_sha256")
|
||||
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
|
||||
|
||||
|
||||
def _declared_metadata_hash(summary: dict[str, Any]) -> str | None:
|
||||
hashes = summary.get("artifact_hashes")
|
||||
if not isinstance(hashes, dict):
|
||||
return None
|
||||
value = hashes.get("metadata_jsonl_sha256")
|
||||
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
|
||||
|
||||
|
||||
def _sha256_stable(path: Path) -> str:
|
||||
try:
|
||||
current = path.lstat()
|
||||
except OSError:
|
||||
return ""
|
||||
if not stat.S_ISREG(current.st_mode):
|
||||
return ""
|
||||
return _sha256_cached(str(path), _stat_identity(current))
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _sha256_cached(
|
||||
path_text: str,
|
||||
expected_identity: tuple[int, int, int, int, int],
|
||||
) -> str:
|
||||
path = Path(path_text)
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
except OSError:
|
||||
return ""
|
||||
try:
|
||||
before = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(before.st_mode) or _stat_identity(before) != expected_identity:
|
||||
return ""
|
||||
digest = hashlib.sha256()
|
||||
while chunk := os.read(descriptor, 1024 * 1024):
|
||||
digest.update(chunk)
|
||||
after = os.fstat(descriptor)
|
||||
try:
|
||||
current = os.lstat(path)
|
||||
except OSError:
|
||||
return ""
|
||||
if _stat_identity(before) != _stat_identity(after) or (
|
||||
current.st_dev,
|
||||
current.st_ino,
|
||||
) != (before.st_dev, before.st_ino):
|
||||
return ""
|
||||
return digest.hexdigest()
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _stat_identity(value: os.stat_result) -> tuple[int, int, int, int, int]:
|
||||
return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
|
||||
|
||||
|
||||
def _active_session_root(root: Path) -> Path | None:
|
||||
marker = root / ".current_session"
|
||||
try:
|
||||
marker_stat = marker.lstat()
|
||||
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
|
||||
return None
|
||||
value = marker.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
if not value:
|
||||
return None
|
||||
raw = Path(value).expanduser()
|
||||
if raw.is_absolute():
|
||||
candidate = raw
|
||||
elif raw.parts and raw.parts[0] == root.name:
|
||||
candidate = root.parent / raw
|
||||
else:
|
||||
candidate = root / raw
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
|
||||
|
||||
|
||||
def _modalities(topic_counts: Mapping[str, int]) -> tuple[SessionModality, ...]:
|
||||
topics = {
|
||||
topic
|
||||
for topic, count in topic_counts.items()
|
||||
if isinstance(topic, str) and _non_negative_int(count) > 0
|
||||
}
|
||||
result: list[SessionModality] = []
|
||||
if any(topic == "RealtimePointcloud" or topic.endswith("/lio_pcl") for topic in topics):
|
||||
result.append("point-cloud")
|
||||
if any(topic == "RealtimePath" or topic.endswith("/lio_pose") for topic in topics):
|
||||
result.append("trajectory")
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _status(replayable: bool, error: object, stop_reason: object) -> SessionStatus:
|
||||
if error is None and stop_reason in {
|
||||
"duration_elapsed",
|
||||
"external_stop",
|
||||
"keyboard_interrupt",
|
||||
}:
|
||||
return "ready" if replayable else "failed"
|
||||
return "interrupted" if replayable else "failed"
|
||||
|
||||
|
||||
def _discover_media_sources(session_root: Path) -> tuple[LegacyMediaSourceCandidate, ...]:
|
||||
media_root = session_root / "media"
|
||||
try:
|
||||
resolved_media_root = media_root.resolve(strict=True)
|
||||
except OSError:
|
||||
return ()
|
||||
if not resolved_media_root.is_dir() or not resolved_media_root.is_relative_to(session_root):
|
||||
return ()
|
||||
candidates: list[LegacyMediaSourceCandidate] = []
|
||||
for source_root in sorted(resolved_media_root.iterdir()):
|
||||
if not source_root.is_dir() or not MEDIA_SOURCE_PATTERN.fullmatch(source_root.name):
|
||||
continue
|
||||
try:
|
||||
resolved_source_root = source_root.resolve(strict=True)
|
||||
except OSError:
|
||||
continue
|
||||
if not resolved_source_root.is_relative_to(resolved_media_root):
|
||||
continue
|
||||
epochs = [
|
||||
epoch
|
||||
for epoch in sorted(resolved_source_root.iterdir())
|
||||
if epoch.is_dir()
|
||||
and MEDIA_EPOCH_PATTERN.fullmatch(epoch.name)
|
||||
and _validated_media_epoch(epoch, resolved_source_root.name)
|
||||
]
|
||||
if not epochs:
|
||||
continue
|
||||
byte_length = sum(_media_epoch_bytes(epoch) for epoch in epochs)
|
||||
artifact_suffix = hashlib.sha256(resolved_source_root.name.encode()).hexdigest()[:16]
|
||||
candidates.append(
|
||||
LegacyMediaSourceCandidate(
|
||||
source_id=resolved_source_root.name,
|
||||
artifact_id=f"recorded-video-{artifact_suffix}",
|
||||
locator=resolved_source_root,
|
||||
byte_length=byte_length,
|
||||
epoch_count=len(epochs),
|
||||
)
|
||||
)
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
|
||||
try:
|
||||
resolved_epoch = epoch.resolve(strict=True)
|
||||
init_path = (epoch / "init.mp4").resolve(strict=True)
|
||||
segments_root = (epoch / "segments").resolve(strict=True)
|
||||
index_path = (epoch / "index.jsonl").resolve(strict=True)
|
||||
summary_path = (epoch / "summary.json").resolve(strict=True)
|
||||
except OSError:
|
||||
return False
|
||||
if not all(
|
||||
path.is_relative_to(resolved_epoch)
|
||||
for path in (init_path, segments_root, index_path, summary_path)
|
||||
):
|
||||
return False
|
||||
if (
|
||||
not init_path.is_file()
|
||||
or init_path.stat().st_size <= 0
|
||||
or not segments_root.is_dir()
|
||||
or not index_path.is_file()
|
||||
or not 0 < index_path.stat().st_size <= MAX_MEDIA_INDEX_BYTES
|
||||
or not summary_path.is_file()
|
||||
):
|
||||
return False
|
||||
summary = _read_json_object(summary_path)
|
||||
if summary.get("schema_version") not in {1, "missioncore.camera-recording/v1"}:
|
||||
return False
|
||||
if summary.get("source_id") != expected_source_id:
|
||||
return False
|
||||
segment_count = _non_negative_int(summary.get("segment_count"))
|
||||
if segment_count < 1:
|
||||
return False
|
||||
segments = [
|
||||
path.resolve()
|
||||
for path in sorted(segments_root.iterdir())
|
||||
if path.is_file() and MEDIA_SEGMENT_PATTERN.fullmatch(path.name)
|
||||
]
|
||||
if len(segments) != segment_count or any(
|
||||
not path.is_relative_to(segments_root) or path.stat().st_size <= 0 for path in segments
|
||||
):
|
||||
return False
|
||||
try:
|
||||
index_records = [
|
||||
json.loads(line)
|
||||
for line in index_path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False
|
||||
if len(index_records) != segment_count or not all(
|
||||
isinstance(record, dict)
|
||||
and _non_negative_int(record.get("sequence")) > 0
|
||||
for record in index_records
|
||||
):
|
||||
return False
|
||||
sequences = [int(record["sequence"]) for record in index_records]
|
||||
return len(sequences) == len(set(sequences))
|
||||
|
||||
|
||||
def _media_epoch_bytes(epoch: Path) -> int:
|
||||
try:
|
||||
resolved_epoch = epoch.resolve(strict=True)
|
||||
except OSError:
|
||||
return 0
|
||||
total = 0
|
||||
for path in resolved_epoch.rglob("*"):
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError:
|
||||
continue
|
||||
if resolved.is_file() and resolved.is_relative_to(resolved_epoch):
|
||||
total += resolved.stat().st_size
|
||||
return total
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,221 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
SessionStatus = Literal["ready", "interrupted", "failed"]
|
||||
SessionModality = Literal["point-cloud", "trajectory", "video"]
|
||||
|
||||
|
||||
class SessionStoreError(RuntimeError):
|
||||
"""Base error for the host-owned observation session store."""
|
||||
|
||||
|
||||
class SessionNotFoundError(SessionStoreError):
|
||||
"""The requested opaque session identifier is not registered."""
|
||||
|
||||
|
||||
class SessionNotReplayableError(SessionStoreError):
|
||||
"""The session has no reviewed replay source."""
|
||||
|
||||
|
||||
class SessionIntegrityError(SessionStoreError):
|
||||
"""A stored artifact no longer satisfies its confinement/integrity boundary."""
|
||||
|
||||
|
||||
class LayoutConflictError(SessionStoreError):
|
||||
"""A workspace layout revision changed since the caller loaded it."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionSource:
|
||||
source_id: str
|
||||
semantic_channel_id: str
|
||||
modality: SessionModality
|
||||
status: str
|
||||
seekable: bool
|
||||
artifact_id: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source_id": self.source_id,
|
||||
"semantic_channel_id": self.semantic_channel_id,
|
||||
"modality": self.modality,
|
||||
"status": self.status,
|
||||
"seekable": self.seekable,
|
||||
"artifact_id": self.artifact_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionArtifact:
|
||||
artifact_id: str
|
||||
kind: str
|
||||
media_type: str
|
||||
byte_length: int
|
||||
sha256: str | None
|
||||
integrity_status: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"artifact_id": self.artifact_id,
|
||||
"kind": self.kind,
|
||||
"media_type": self.media_type,
|
||||
"byte_length": self.byte_length,
|
||||
"sha256": self.sha256,
|
||||
"integrity_status": self.integrity_status,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionSummary:
|
||||
session_id: str
|
||||
display_name: str
|
||||
status: SessionStatus
|
||||
started_at_utc: str | None
|
||||
completed_at_utc: str | None
|
||||
duration_seconds: float | None
|
||||
modalities: tuple[SessionModality, ...]
|
||||
source_count: int
|
||||
total_bytes: int
|
||||
replayable: bool
|
||||
origin: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.observation-session-summary/v1",
|
||||
"session_id": self.session_id,
|
||||
"display_name": self.display_name,
|
||||
"status": self.status,
|
||||
"started_at_utc": self.started_at_utc,
|
||||
"completed_at_utc": self.completed_at_utc,
|
||||
"duration_seconds": self.duration_seconds,
|
||||
"modalities": list(self.modalities),
|
||||
"source_count": self.source_count,
|
||||
"total_bytes": self.total_bytes,
|
||||
"replayable": self.replayable,
|
||||
"origin": self.origin,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionDetail:
|
||||
summary: SessionSummary
|
||||
sources: tuple[SessionSource, ...]
|
||||
artifacts: tuple[SessionArtifact, ...]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
duration = self.summary.duration_seconds
|
||||
return {
|
||||
"schema_version": "missioncore.observation-session/v1",
|
||||
**{
|
||||
key: value
|
||||
for key, value in self.summary.as_dict().items()
|
||||
if key != "schema_version"
|
||||
},
|
||||
"timeline": {
|
||||
"mode": "recorded" if self.summary.replayable else "unavailable",
|
||||
"seekable": self.summary.replayable,
|
||||
"start_seconds": 0.0 if self.summary.replayable else None,
|
||||
"end_seconds": duration if self.summary.replayable else None,
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
},
|
||||
"sources": [source.as_dict() for source in self.sources],
|
||||
"artifacts": [artifact.as_dict() for artifact in self.artifacts],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionPage:
|
||||
items: tuple[SessionSummary, ...]
|
||||
next_cursor: str | None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.observation-session-list/v1",
|
||||
"items": [item.as_dict() for item in self.items],
|
||||
"next_cursor": self.next_cursor,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkspaceLayout:
|
||||
workspace_id: str
|
||||
schema_version: int
|
||||
revision: int
|
||||
name: str
|
||||
layout: dict[str, Any]
|
||||
updated_at_utc: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.workspace-layout/v1",
|
||||
"workspace_id": self.workspace_id,
|
||||
"layout_schema_version": self.schema_version,
|
||||
"revision": self.revision,
|
||||
"name": self.name,
|
||||
"layout": self.layout,
|
||||
"updated_at_utc": self.updated_at_utc,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayCommand:
|
||||
"""Internal-only replay command. ``source_path`` never enters an API DTO."""
|
||||
|
||||
session_id: str
|
||||
source_path: Path
|
||||
allowed_root: Path
|
||||
session_root: Path
|
||||
replay_byte_length: int
|
||||
metadata_byte_length: int
|
||||
expected_source_sha256: str | None
|
||||
speed: float
|
||||
loop: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedMediaArtifact:
|
||||
"""Internal handle for one confined archived camera source.
|
||||
|
||||
The physical source id and filesystem locator never enter the public API.
|
||||
``public_source_id`` and ``artifact_id`` are opaque catalog identifiers.
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
public_source_id: str
|
||||
artifact_id: str
|
||||
source_path: Path
|
||||
byte_length: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LegacySessionCandidate:
|
||||
session_id: str
|
||||
display_name: str
|
||||
status: SessionStatus
|
||||
started_at_utc: str | None
|
||||
completed_at_utc: str | None
|
||||
duration_seconds: float | None
|
||||
modalities: tuple[SessionModality, ...]
|
||||
replayable: bool
|
||||
total_bytes: int
|
||||
allowed_root: Path
|
||||
session_root: Path
|
||||
raw_path: Path
|
||||
raw_byte_length: int
|
||||
replay_raw_byte_length: int
|
||||
replay_metadata_byte_length: int
|
||||
raw_sha256: str | None
|
||||
raw_integrity_status: str
|
||||
media_sources: tuple[LegacyMediaSourceCandidate, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LegacyMediaSourceCandidate:
|
||||
source_id: str
|
||||
artifact_id: str
|
||||
locator: Path
|
||||
byte_length: int
|
||||
epoch_count: int
|
||||
|
|
@ -0,0 +1,617 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import UTC, datetime
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
from typing import Literal, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from .media import RecordedMediaManifest, validate_recorded_media_timeline
|
||||
from .models import ReplayCommand
|
||||
from .recording import (
|
||||
MaterializedRecording,
|
||||
RecordingMaterializationCancelled,
|
||||
SessionRecordingMaterializer,
|
||||
)
|
||||
|
||||
PreparationState = Literal[
|
||||
"queued",
|
||||
"validating",
|
||||
"exporting",
|
||||
"finalizing",
|
||||
"ready",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]
|
||||
ACTIVE_PREPARATION_STATES = frozenset({"queued", "validating", "exporting", "finalizing"})
|
||||
_PREPARATION_PHASE: dict[PreparationState, int] = {
|
||||
"queued": 0,
|
||||
"validating": 1,
|
||||
"exporting": 2,
|
||||
"finalizing": 3,
|
||||
"ready": 4,
|
||||
"failed": 4,
|
||||
"cancelled": 4,
|
||||
}
|
||||
|
||||
|
||||
class RecordingPreparationQueueFull(RuntimeError):
|
||||
"""The bounded conversion queue cannot accept another recording."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordingPreparationSnapshot:
|
||||
preparation_id: str
|
||||
session_id: str
|
||||
state: PreparationState
|
||||
progress: float
|
||||
updated_at_utc: str
|
||||
cancellable: bool
|
||||
retryable: bool
|
||||
error: str | None
|
||||
command: ReplayCommand
|
||||
recording: MaterializedRecording | None
|
||||
recorded_media: tuple[RecordedMediaManifest, ...] | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PreparationJob:
|
||||
preparation_id: str
|
||||
source_identity: tuple[object, ...]
|
||||
# This is an immutable source-preparation command. Per-browser playback
|
||||
# policy (speed/loop) never belongs to a shared conversion job.
|
||||
command: ReplayCommand
|
||||
state: PreparationState = "queued"
|
||||
progress: float = 0.0
|
||||
updated_at_utc: str = field(default_factory=lambda: _utc_now_iso())
|
||||
error: str | None = None
|
||||
recording: MaterializedRecording | None = None
|
||||
recorded_media: tuple[RecordedMediaManifest, ...] | None = None
|
||||
cancel_event: threading.Event = field(default_factory=threading.Event)
|
||||
last_activity_monotonic: float = field(default_factory=monotonic)
|
||||
interrupted_by_restart: bool = False
|
||||
cancelled_by_operator: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _WorkerGeneration:
|
||||
generation_id: int
|
||||
work_queue: queue.Queue[_PreparationJob]
|
||||
stop_event: threading.Event = field(default_factory=threading.Event)
|
||||
worker: threading.Thread | None = None
|
||||
|
||||
|
||||
class SessionRecordingPreparationManager:
|
||||
"""One bounded, process-owned conversion worker for durable recordings.
|
||||
|
||||
Jobs are keyed by the session plus an inexpensive source identity. They
|
||||
intentionally outlive HTTP requests and browser tabs. Conversion stays
|
||||
single-worker to bound Rerun's CPU, memory and temporary-disk pressure.
|
||||
|
||||
Worker generations make lifespan restart safe even if a third-party
|
||||
exporter ignores cancellation longer than ``close(timeout=...)``. New
|
||||
jobs can queue immediately, but a successor worker starts only after the
|
||||
previous generation has actually exited, so two writers never overlap.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
materializer: SessionRecordingMaterializer,
|
||||
*,
|
||||
queue_capacity: int = 128,
|
||||
heartbeat_interval_seconds: float = 5.0,
|
||||
ready_preparer: Callable[
|
||||
[ReplayCommand, MaterializedRecording],
|
||||
tuple[RecordedMediaManifest, ...],
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
if queue_capacity < 1:
|
||||
raise ValueError("recording preparation queue capacity must be positive")
|
||||
if heartbeat_interval_seconds <= 0:
|
||||
raise ValueError("recording preparation heartbeat interval must be positive")
|
||||
self.materializer = materializer
|
||||
self._queue_capacity = queue_capacity
|
||||
# Activity callbacks from the real exporter are rate-limited to this
|
||||
# interval. There is deliberately no independent fake heartbeat: a
|
||||
# hung exporter must become observable to the browser stall detector.
|
||||
self._heartbeat_interval_seconds = heartbeat_interval_seconds
|
||||
self._ready_preparer = ready_preparer
|
||||
self._guard = threading.RLock()
|
||||
self._current_by_session: dict[str, _PreparationJob] = {}
|
||||
self._closed = True
|
||||
self._generation_counter = 0
|
||||
self._active_generation: _WorkerGeneration | None = None
|
||||
self._pending_generation: _WorkerGeneration | None = None
|
||||
self.start()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start or restart the single worker for an application lifespan."""
|
||||
|
||||
with self._guard:
|
||||
if not self._closed:
|
||||
return
|
||||
self._current_by_session = {
|
||||
session_id: job
|
||||
for session_id, job in self._current_by_session.items()
|
||||
if job.state != "cancelled"
|
||||
}
|
||||
self._closed = False
|
||||
active = self._active_generation
|
||||
if active is not None and active.worker is not None and active.worker.is_alive():
|
||||
self._pending_generation = self._new_generation_locked()
|
||||
return
|
||||
self._active_generation = None
|
||||
generation = self._pending_generation or self._new_generation_locked()
|
||||
self._pending_generation = None
|
||||
self._start_generation_locked(generation)
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
command: ReplayCommand,
|
||||
*,
|
||||
retry_failed: bool = False,
|
||||
retry_interrupted: bool = False,
|
||||
) -> RecordingPreparationSnapshot:
|
||||
source_command = _source_command(command)
|
||||
identity = _source_identity(source_command)
|
||||
with self._guard:
|
||||
if self._closed:
|
||||
raise RuntimeError("recording preparation manager is closed")
|
||||
current = self._current_by_session.get(command.session_id)
|
||||
if current is not None and current.source_identity == identity:
|
||||
retry_terminal = retry_failed and current.state in {"failed", "cancelled"}
|
||||
retry_restart = (
|
||||
retry_interrupted
|
||||
and current.state == "cancelled"
|
||||
and current.interrupted_by_restart
|
||||
)
|
||||
if not retry_terminal and not retry_restart:
|
||||
return self._snapshot_locked(current)
|
||||
|
||||
job = _PreparationJob(
|
||||
preparation_id=uuid4().hex,
|
||||
source_identity=identity,
|
||||
command=source_command,
|
||||
)
|
||||
self._current_by_session[command.session_id] = job
|
||||
try:
|
||||
self._intake_queue_locked().put_nowait(job)
|
||||
except queue.Full as exc:
|
||||
if self._current_by_session.get(command.session_id) is job:
|
||||
if current is None:
|
||||
self._current_by_session.pop(command.session_id, None)
|
||||
else:
|
||||
self._current_by_session[command.session_id] = current
|
||||
raise RecordingPreparationQueueFull("recording preparation queue is full") from exc
|
||||
return self._snapshot_locked(job)
|
||||
|
||||
def resolve_cached(
|
||||
self,
|
||||
command: ReplayCommand,
|
||||
) -> RecordingPreparationSnapshot | None:
|
||||
"""Validate a published cache and register it as a ready job."""
|
||||
|
||||
# When launch preparation includes camera manifests, a disk-only RRD
|
||||
# is not a complete ready result. The worker must validate both parts
|
||||
# in one background transaction before publishing ``ready``.
|
||||
if self._ready_preparer is not None:
|
||||
return None
|
||||
|
||||
source_command = _source_command(command)
|
||||
identity = _source_identity(source_command)
|
||||
recording = self.materializer.get_cached(source_command)
|
||||
if recording is None:
|
||||
with self._guard:
|
||||
current = self._current_by_session.get(command.session_id)
|
||||
if current is not None and current.state == "ready":
|
||||
self._current_by_session.pop(command.session_id, None)
|
||||
return None
|
||||
with self._guard:
|
||||
current = self._current_by_session.get(command.session_id)
|
||||
if (
|
||||
current is not None
|
||||
and current.source_identity == identity
|
||||
and current.state in ACTIVE_PREPARATION_STATES | {"ready"}
|
||||
):
|
||||
# A worker may have started between the disk lookup and this
|
||||
# transaction. Let that single job publish its own result.
|
||||
if current.state == "ready":
|
||||
current.recording = recording
|
||||
return self._snapshot_locked(current)
|
||||
ready = _PreparationJob(
|
||||
preparation_id=uuid4().hex,
|
||||
source_identity=identity,
|
||||
command=source_command,
|
||||
state="ready",
|
||||
progress=1.0,
|
||||
recording=recording,
|
||||
recorded_media=(),
|
||||
)
|
||||
self._current_by_session[command.session_id] = ready
|
||||
return self._snapshot_locked(ready)
|
||||
|
||||
def resolve_cached_pinned(
|
||||
self,
|
||||
command: ReplayCommand,
|
||||
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
|
||||
"""Validate and lease a published cache without starting conversion."""
|
||||
|
||||
if self._ready_preparer is not None:
|
||||
return None
|
||||
|
||||
source_command = _source_command(command)
|
||||
result = self.materializer.get_cached_pinned(source_command)
|
||||
if result is None:
|
||||
with self._guard:
|
||||
current = self._current_by_session.get(command.session_id)
|
||||
if current is not None and current.state == "ready":
|
||||
self._current_by_session.pop(command.session_id, None)
|
||||
return None
|
||||
recording, release = result
|
||||
identity = _source_identity(source_command)
|
||||
with self._guard:
|
||||
current = self._current_by_session.get(command.session_id)
|
||||
if (
|
||||
current is None
|
||||
or current.source_identity != identity
|
||||
or current.state not in ACTIVE_PREPARATION_STATES | {"ready"}
|
||||
):
|
||||
current = _PreparationJob(
|
||||
preparation_id=uuid4().hex,
|
||||
source_identity=identity,
|
||||
command=source_command,
|
||||
state="ready",
|
||||
progress=1.0,
|
||||
recording=recording,
|
||||
recorded_media=(),
|
||||
)
|
||||
self._current_by_session[command.session_id] = current
|
||||
snapshot = self._snapshot_locked(current)
|
||||
return snapshot, release
|
||||
|
||||
def status(self, session_id: str) -> RecordingPreparationSnapshot | None:
|
||||
with self._guard:
|
||||
job = self._current_by_session.get(session_id)
|
||||
if (
|
||||
job is not None
|
||||
and job.state == "ready"
|
||||
and (
|
||||
job.recording is None
|
||||
or not self.materializer.is_recording_available(job.recording)
|
||||
)
|
||||
):
|
||||
self._current_by_session.pop(session_id, None)
|
||||
return None
|
||||
return None if job is None else self._snapshot_locked(job)
|
||||
|
||||
def reserve_cached(
|
||||
self,
|
||||
command: ReplayCommand,
|
||||
*,
|
||||
lease_seconds: float = 120.0,
|
||||
) -> RecordingPreparationSnapshot | None:
|
||||
"""Pin a ready artifact across the launch-document to file-GET gap."""
|
||||
|
||||
if lease_seconds <= 0:
|
||||
raise ValueError("recording launch lease must be positive")
|
||||
pinned = self.resolve_cached_pinned(command)
|
||||
if pinned is None:
|
||||
return None
|
||||
snapshot, release = pinned
|
||||
timer = threading.Timer(lease_seconds, release)
|
||||
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
return snapshot
|
||||
|
||||
def pin_ready(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
preparation_id: str | None = None,
|
||||
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
|
||||
"""Cheaply lease the exact already-validated ready generation."""
|
||||
|
||||
with self._guard:
|
||||
job = self._current_by_session.get(session_id)
|
||||
if (
|
||||
job is None
|
||||
or job.state != "ready"
|
||||
or job.recording is None
|
||||
or (
|
||||
preparation_id is not None
|
||||
and job.preparation_id != preparation_id
|
||||
)
|
||||
):
|
||||
return None
|
||||
recording = job.recording
|
||||
pinned = self.materializer.pin_recording(recording)
|
||||
if pinned is None:
|
||||
if self._current_by_session.get(session_id) is job:
|
||||
self._current_by_session.pop(session_id, None)
|
||||
return None
|
||||
snapshot = self._snapshot_locked(job)
|
||||
return snapshot, pinned
|
||||
|
||||
def reserve_ready(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
preparation_id: str | None = None,
|
||||
lease_seconds: float = 120.0,
|
||||
) -> RecordingPreparationSnapshot | None:
|
||||
"""Hold a cheap launch lease without reopening or hashing the cache."""
|
||||
|
||||
if lease_seconds <= 0:
|
||||
raise ValueError("recording launch lease must be positive")
|
||||
pinned = self.pin_ready(session_id, preparation_id=preparation_id)
|
||||
if pinned is None:
|
||||
return None
|
||||
snapshot, release = pinned
|
||||
timer = threading.Timer(lease_seconds, release)
|
||||
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
return snapshot
|
||||
|
||||
def cancel(self, session_id: str, *, preparation_id: str | None = None) -> bool:
|
||||
with self._guard:
|
||||
job = self._current_by_session.get(session_id)
|
||||
if (
|
||||
job is None
|
||||
or job.state not in ACTIVE_PREPARATION_STATES
|
||||
or (preparation_id is not None and job.preparation_id != preparation_id)
|
||||
):
|
||||
return False
|
||||
job.cancelled_by_operator = True
|
||||
job.cancel_event.set()
|
||||
if job.state == "queued":
|
||||
self._transition_locked(job, "cancelled", job.progress)
|
||||
return True
|
||||
|
||||
def close(self, *, timeout: float = 5.0) -> None:
|
||||
with self._guard:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
for job in self._current_by_session.values():
|
||||
if job.state in ACTIVE_PREPARATION_STATES:
|
||||
job.interrupted_by_restart = not job.cancelled_by_operator
|
||||
job.cancel_event.set()
|
||||
if job.state == "queued":
|
||||
self._transition_locked(job, "cancelled", job.progress)
|
||||
active = self._active_generation
|
||||
if active is not None:
|
||||
active.stop_event.set()
|
||||
pending = self._pending_generation
|
||||
if pending is not None:
|
||||
pending.stop_event.set()
|
||||
self._pending_generation = None
|
||||
worker = None if active is None else active.worker
|
||||
if worker is not None:
|
||||
worker.join(timeout=max(0.0, timeout))
|
||||
|
||||
def _run_generation(self, generation: _WorkerGeneration) -> None:
|
||||
work_queue = generation.work_queue
|
||||
try:
|
||||
while not generation.stop_event.is_set():
|
||||
try:
|
||||
job = work_queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
try:
|
||||
if job.state == "cancelled" or job.cancel_event.is_set():
|
||||
with self._guard:
|
||||
self._transition_locked(job, "cancelled", job.progress)
|
||||
continue
|
||||
with self._guard:
|
||||
self._transition_locked(job, "validating", 0.05)
|
||||
try:
|
||||
recording = self.materializer.materialize(
|
||||
job.command,
|
||||
progress_callback=partial(self._progress, job),
|
||||
cancel_event=job.cancel_event,
|
||||
)
|
||||
except RecordingMaterializationCancelled:
|
||||
with self._guard:
|
||||
self._transition_locked(job, "cancelled", job.progress)
|
||||
except Exception:
|
||||
# The public status intentionally does not expose paths,
|
||||
# broker payloads or exporter internals.
|
||||
with self._guard:
|
||||
if job.cancel_event.is_set():
|
||||
self._transition_locked(job, "cancelled", job.progress)
|
||||
else:
|
||||
self._transition_locked(job, "failed", job.progress)
|
||||
job.error = "Не удалось подготовить запись сессии."
|
||||
job.updated_at_utc = _utc_now_iso()
|
||||
else:
|
||||
with self._guard:
|
||||
# Cancellation may race the final materializer
|
||||
# return. Never publish ``ready`` after accepting a
|
||||
# cancel request for this exact job.
|
||||
if job.cancel_event.is_set():
|
||||
self._transition_locked(job, "cancelled", job.progress)
|
||||
else:
|
||||
self._transition_locked(job, "finalizing", 0.95)
|
||||
if job.cancel_event.is_set():
|
||||
with self._guard:
|
||||
self._transition_locked(job, "cancelled", job.progress)
|
||||
continue
|
||||
try:
|
||||
recorded_media = (
|
||||
()
|
||||
if self._ready_preparer is None
|
||||
else self._ready_preparer(job.command, recording)
|
||||
)
|
||||
validate_recorded_media_timeline(
|
||||
recorded_media,
|
||||
recording_start_seconds=(
|
||||
recording.timeline_start_ns / 1_000_000_000
|
||||
),
|
||||
recording_end_seconds=(
|
||||
recording.timeline_end_ns / 1_000_000_000
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
with self._guard:
|
||||
if job.cancel_event.is_set():
|
||||
self._transition_locked(job, "cancelled", job.progress)
|
||||
else:
|
||||
self._transition_locked(job, "failed", job.progress)
|
||||
job.error = "Не удалось подготовить запись сессии."
|
||||
job.updated_at_utc = _utc_now_iso()
|
||||
continue
|
||||
with self._guard:
|
||||
if job.cancel_event.is_set():
|
||||
self._transition_locked(job, "cancelled", job.progress)
|
||||
else:
|
||||
job.recording = recording
|
||||
job.recorded_media = recorded_media
|
||||
self._transition_locked(job, "ready", 1.0)
|
||||
finally:
|
||||
work_queue.task_done()
|
||||
finally:
|
||||
self._generation_exited(generation)
|
||||
|
||||
def _new_generation_locked(self) -> _WorkerGeneration:
|
||||
self._generation_counter += 1
|
||||
return _WorkerGeneration(
|
||||
generation_id=self._generation_counter,
|
||||
work_queue=queue.Queue(maxsize=self._queue_capacity),
|
||||
)
|
||||
|
||||
def _start_generation_locked(self, generation: _WorkerGeneration) -> None:
|
||||
self._active_generation = generation
|
||||
worker = threading.Thread(
|
||||
target=self._run_generation,
|
||||
args=(generation,),
|
||||
name=f"missioncore-recording-preparation-{generation.generation_id}",
|
||||
daemon=True,
|
||||
)
|
||||
generation.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _generation_exited(self, generation: _WorkerGeneration) -> None:
|
||||
with self._guard:
|
||||
if self._active_generation is not generation:
|
||||
return
|
||||
self._active_generation = None
|
||||
if self._closed:
|
||||
return
|
||||
successor = self._pending_generation or self._new_generation_locked()
|
||||
self._pending_generation = None
|
||||
self._start_generation_locked(successor)
|
||||
|
||||
def _intake_queue_locked(self) -> queue.Queue[_PreparationJob]:
|
||||
pending = self._pending_generation
|
||||
if pending is not None:
|
||||
return pending.work_queue
|
||||
active = self._active_generation
|
||||
if active is None or active.stop_event.is_set():
|
||||
pending = self._new_generation_locked()
|
||||
self._pending_generation = pending
|
||||
return pending.work_queue
|
||||
return active.work_queue
|
||||
|
||||
def _progress(self, job: _PreparationJob, state: str, progress: float) -> None:
|
||||
# Only the worker publishes ``ready`` after it owns the verified
|
||||
# recording handle; accepting the final callback would expose a short
|
||||
# ready-without-launch race to status polling.
|
||||
if state not in {"validating", "exporting", "finalizing"}:
|
||||
return
|
||||
next_state = cast(PreparationState, state)
|
||||
with self._guard:
|
||||
if job.state in {"cancelled", "failed", "ready"}:
|
||||
return
|
||||
if _PREPARATION_PHASE[next_state] < _PREPARATION_PHASE[job.state]:
|
||||
return
|
||||
now = monotonic()
|
||||
if (
|
||||
job.state == next_state
|
||||
and progress <= job.progress
|
||||
and now - job.last_activity_monotonic < self._heartbeat_interval_seconds
|
||||
):
|
||||
return
|
||||
self._transition_locked(job, next_state, progress)
|
||||
|
||||
def _transition_locked(
|
||||
self,
|
||||
job: _PreparationJob,
|
||||
state: PreparationState,
|
||||
progress: float,
|
||||
) -> None:
|
||||
if job.state not in ACTIVE_PREPARATION_STATES and state != job.state:
|
||||
return
|
||||
if _PREPARATION_PHASE[state] < _PREPARATION_PHASE[job.state]:
|
||||
return
|
||||
job.state = state
|
||||
job.progress = max(job.progress, min(1.0, max(0.0, progress)))
|
||||
job.updated_at_utc = _utc_now_iso()
|
||||
job.last_activity_monotonic = monotonic()
|
||||
|
||||
def _snapshot_locked(self, job: _PreparationJob) -> RecordingPreparationSnapshot:
|
||||
return RecordingPreparationSnapshot(
|
||||
preparation_id=job.preparation_id,
|
||||
session_id=job.command.session_id,
|
||||
state=job.state,
|
||||
progress=job.progress,
|
||||
updated_at_utc=job.updated_at_utc,
|
||||
cancellable=(
|
||||
job.state == "queued"
|
||||
or (
|
||||
job.state in {"validating", "exporting", "finalizing"}
|
||||
and self.materializer.supports_cooperative_cancellation
|
||||
)
|
||||
),
|
||||
retryable=job.state in {"failed", "cancelled"},
|
||||
error=job.error,
|
||||
command=job.command,
|
||||
recording=job.recording,
|
||||
recorded_media=job.recorded_media,
|
||||
)
|
||||
|
||||
|
||||
def _source_identity(command: ReplayCommand) -> tuple[object, ...]:
|
||||
"""Build a non-blocking identity; full validation belongs to the worker."""
|
||||
|
||||
identities: list[object] = [
|
||||
command.session_id,
|
||||
str(command.source_path),
|
||||
command.replay_byte_length,
|
||||
command.metadata_byte_length,
|
||||
command.expected_source_sha256,
|
||||
]
|
||||
for path in (command.source_path, command.source_path.with_name("mqtt.metadata.jsonl")):
|
||||
try:
|
||||
value = os.lstat(path)
|
||||
except OSError:
|
||||
identities.extend((str(Path(path)), None, None, None, None))
|
||||
else:
|
||||
identities.extend(
|
||||
(
|
||||
str(Path(path)),
|
||||
value.st_size,
|
||||
value.st_mtime_ns,
|
||||
value.st_ctime_ns,
|
||||
value.st_ino,
|
||||
)
|
||||
)
|
||||
return tuple(identities)
|
||||
|
||||
|
||||
def _source_command(command: ReplayCommand) -> ReplayCommand:
|
||||
"""Remove per-viewer launch policy from a shared preparation job."""
|
||||
|
||||
return replace(command, speed=1.0, loop=False)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,715 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
from .legacy import discover_legacy_viewer_sessions
|
||||
from .models import (
|
||||
LayoutConflictError,
|
||||
LegacySessionCandidate,
|
||||
RecordedMediaArtifact,
|
||||
ReplayCommand,
|
||||
SessionArtifact,
|
||||
SessionDetail,
|
||||
SessionIntegrityError,
|
||||
SessionModality,
|
||||
SessionNotFoundError,
|
||||
SessionNotReplayableError,
|
||||
SessionPage,
|
||||
SessionSource,
|
||||
SessionStatus,
|
||||
SessionSummary,
|
||||
WorkspaceLayout,
|
||||
)
|
||||
|
||||
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
MAX_LAYOUT_BYTES = 256 * 1024
|
||||
DATABASE_NAME = "mission-core.sqlite3"
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS observation_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ready', 'interrupted', 'failed')),
|
||||
started_at_utc TEXT,
|
||||
completed_at_utc TEXT,
|
||||
duration_seconds REAL,
|
||||
modalities_json TEXT NOT NULL,
|
||||
replayable INTEGER NOT NULL CHECK (replayable IN (0, 1)),
|
||||
origin TEXT NOT NULL,
|
||||
source_count INTEGER NOT NULL,
|
||||
total_bytes INTEGER NOT NULL,
|
||||
replay_raw_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
replay_metadata_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
allowed_root TEXT NOT NULL,
|
||||
session_root TEXT NOT NULL,
|
||||
created_at_utc TEXT NOT NULL,
|
||||
updated_at_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS observation_sessions_recent
|
||||
ON observation_sessions(started_at_utc DESC, session_id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS observation_session_artifacts (
|
||||
session_id TEXT NOT NULL REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
|
||||
artifact_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
byte_length INTEGER NOT NULL,
|
||||
sha256 TEXT,
|
||||
integrity_status TEXT NOT NULL,
|
||||
locator TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, artifact_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS observation_session_sources (
|
||||
session_id TEXT NOT NULL REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
|
||||
source_id TEXT NOT NULL,
|
||||
semantic_channel_id TEXT NOT NULL,
|
||||
modality TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
seekable INTEGER NOT NULL CHECK (seekable IN (0, 1)),
|
||||
artifact_id TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, source_id),
|
||||
FOREIGN KEY (session_id, artifact_id)
|
||||
REFERENCES observation_session_artifacts(session_id, artifact_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_layouts (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
layout_schema_version INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
layout_json TEXT NOT NULL,
|
||||
updated_at_utc TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def resolve_missioncore_data_dir(repository_root: Path) -> Path:
|
||||
configured = os.environ.get("MISSIONCORE_DATA_DIR", "").strip()
|
||||
if configured:
|
||||
return Path(configured).expanduser().resolve()
|
||||
return (repository_root.expanduser().resolve() / ".runtime" / "mission-core").resolve()
|
||||
|
||||
|
||||
def resolve_missioncore_evidence_dir(repository_root: Path) -> Path:
|
||||
"""Return the private source-of-record root for new observation sessions."""
|
||||
|
||||
configured = os.environ.get("MISSIONCORE_EVIDENCE_DIR", "").strip()
|
||||
if configured:
|
||||
return Path(configured).expanduser().resolve()
|
||||
return (resolve_missioncore_data_dir(repository_root) / "evidence" / "sessions").resolve()
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""SQLite catalog plus confined filesystem references for host observation sessions."""
|
||||
|
||||
def __init__(self, repository_root: Path, *, data_dir: Path | None = None) -> None:
|
||||
self.repository_root = repository_root.expanduser().resolve()
|
||||
self.data_dir = (
|
||||
data_dir.expanduser().resolve()
|
||||
if data_dir is not None
|
||||
else resolve_missioncore_data_dir(self.repository_root)
|
||||
)
|
||||
self.data_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with _ignore_os_error():
|
||||
self.data_dir.chmod(0o700)
|
||||
self.database_path = self.data_dir / DATABASE_NAME
|
||||
self._lock = threading.RLock()
|
||||
self._initialize()
|
||||
|
||||
def import_legacy_viewer_live(self, root: Path) -> tuple[str, ...]:
|
||||
allowed_root = root.expanduser().resolve()
|
||||
candidates = discover_legacy_viewer_sessions(allowed_root)
|
||||
imported: list[str] = []
|
||||
for candidate in candidates:
|
||||
self._upsert_legacy(candidate)
|
||||
imported.append(candidate.session_id)
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
discovered = set(imported)
|
||||
indexed = connection.execute(
|
||||
"SELECT session_id FROM observation_sessions "
|
||||
"WHERE origin = 'legacy-viewer-live' AND allowed_root = ?",
|
||||
(str(allowed_root),),
|
||||
).fetchall()
|
||||
stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered]
|
||||
connection.executemany(
|
||||
"DELETE FROM observation_sessions WHERE session_id = ?",
|
||||
((session_id,) for session_id in stale),
|
||||
)
|
||||
connection.commit()
|
||||
return tuple(imported)
|
||||
|
||||
def list_recent(self, *, limit: int = 20, cursor: str | None = None) -> SessionPage:
|
||||
if not 1 <= limit <= 100:
|
||||
raise ValueError("limit must be within 1..100")
|
||||
parameters: list[object] = []
|
||||
where = ""
|
||||
with self._connect() as connection:
|
||||
if cursor is not None:
|
||||
_validate_identifier(cursor, "session cursor")
|
||||
cursor_row = connection.execute(
|
||||
"SELECT started_at_utc, session_id FROM observation_sessions "
|
||||
"WHERE session_id = ?",
|
||||
(cursor,),
|
||||
).fetchone()
|
||||
if cursor_row is None:
|
||||
raise SessionNotFoundError("observation session cursor was not found")
|
||||
where = (
|
||||
"WHERE (COALESCE(started_at_utc, ''), session_id) < "
|
||||
"(COALESCE(?, ''), ?)"
|
||||
)
|
||||
parameters.extend((cursor_row["started_at_utc"], cursor_row["session_id"]))
|
||||
parameters.append(limit + 1)
|
||||
rows = connection.execute(
|
||||
f"SELECT * FROM observation_sessions {where} " # noqa: S608 - static clause
|
||||
"ORDER BY COALESCE(started_at_utc, '') DESC, session_id DESC LIMIT ?",
|
||||
parameters,
|
||||
).fetchall()
|
||||
has_more = len(rows) > limit
|
||||
selected = rows[:limit]
|
||||
items = tuple(_summary_from_row(row) for row in selected)
|
||||
next_cursor = items[-1].session_id if has_more and items else None
|
||||
return SessionPage(items=items, next_cursor=next_cursor)
|
||||
|
||||
def get_session(self, session_id: str) -> SessionDetail:
|
||||
_validate_identifier(session_id, "session id")
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM observation_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise SessionNotFoundError("observation session was not found")
|
||||
source_rows = connection.execute(
|
||||
"SELECT source_id, semantic_channel_id, modality, status, seekable, artifact_id "
|
||||
"FROM observation_session_sources WHERE session_id = ? ORDER BY source_id",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
artifact_rows = connection.execute(
|
||||
"SELECT artifact_id, kind, media_type, byte_length, sha256, integrity_status "
|
||||
"FROM observation_session_artifacts WHERE session_id = ? ORDER BY artifact_id",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
sources = tuple(
|
||||
SessionSource(
|
||||
source_id=source["source_id"],
|
||||
semantic_channel_id=source["semantic_channel_id"],
|
||||
modality=cast(SessionModality, source["modality"]),
|
||||
status=source["status"],
|
||||
seekable=bool(source["seekable"]),
|
||||
artifact_id=source["artifact_id"],
|
||||
)
|
||||
for source in source_rows
|
||||
)
|
||||
artifacts = tuple(
|
||||
SessionArtifact(
|
||||
artifact_id=artifact["artifact_id"],
|
||||
kind=artifact["kind"],
|
||||
media_type=artifact["media_type"],
|
||||
byte_length=artifact["byte_length"],
|
||||
sha256=artifact["sha256"],
|
||||
integrity_status=artifact["integrity_status"],
|
||||
)
|
||||
for artifact in artifact_rows
|
||||
)
|
||||
return SessionDetail(summary=_summary_from_row(row), sources=sources, artifacts=artifacts)
|
||||
|
||||
def prepare_replay(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
speed: float = 1.0,
|
||||
loop: bool = False,
|
||||
) -> ReplayCommand:
|
||||
detail = self.get_session(session_id)
|
||||
if not detail.summary.replayable:
|
||||
raise SessionNotReplayableError("observation session has no replayable spatial source")
|
||||
if not 0 <= speed <= 100:
|
||||
raise ValueError("speed must be within 0..100")
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT s.allowed_root, s.session_root, s.replay_raw_bytes, "
|
||||
"s.replay_metadata_bytes, a.locator, a.sha256 "
|
||||
"FROM observation_sessions AS s "
|
||||
"JOIN observation_session_artifacts AS a ON a.session_id = s.session_id "
|
||||
"WHERE s.session_id = ? AND a.artifact_id = 'raw-mqtt'",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise SessionNotReplayableError("observation session replay artifact is unavailable")
|
||||
source_path = _resolve_confined_artifact(
|
||||
Path(row["allowed_root"]),
|
||||
Path(row["session_root"]),
|
||||
Path(row["locator"]),
|
||||
)
|
||||
return ReplayCommand(
|
||||
session_id=session_id,
|
||||
source_path=source_path,
|
||||
allowed_root=Path(row["allowed_root"]),
|
||||
session_root=Path(row["session_root"]),
|
||||
replay_byte_length=int(row["replay_raw_bytes"]),
|
||||
metadata_byte_length=int(row["replay_metadata_bytes"]),
|
||||
expected_source_sha256=row["sha256"],
|
||||
speed=float(speed),
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
def list_recorded_media(self, session_id: str) -> tuple[RecordedMediaArtifact, ...]:
|
||||
"""Return confined archived-video handles without exposing device ids."""
|
||||
|
||||
_validate_identifier(session_id, "session id")
|
||||
with self._connect() as connection:
|
||||
session = connection.execute(
|
||||
"SELECT allowed_root, session_root FROM observation_sessions "
|
||||
"WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if session is None:
|
||||
raise SessionNotFoundError("observation session was not found")
|
||||
rows = connection.execute(
|
||||
"SELECT DISTINCT a.artifact_id, a.locator, a.byte_length "
|
||||
"FROM observation_session_artifacts AS a "
|
||||
"JOIN observation_session_sources AS source "
|
||||
"ON source.session_id = a.session_id AND source.artifact_id = a.artifact_id "
|
||||
"WHERE a.session_id = ? AND a.kind = 'recorded-video' "
|
||||
"AND source.modality = 'video' ORDER BY a.artifact_id",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return tuple(
|
||||
_recorded_media_from_row(
|
||||
session_id=session_id,
|
||||
allowed_root=Path(session["allowed_root"]),
|
||||
session_root=Path(session["session_root"]),
|
||||
artifact_id=row["artifact_id"],
|
||||
locator=Path(row["locator"]),
|
||||
byte_length=row["byte_length"],
|
||||
)
|
||||
for row in rows
|
||||
)
|
||||
|
||||
def get_recorded_media(
|
||||
self,
|
||||
session_id: str,
|
||||
artifact_id: str,
|
||||
) -> RecordedMediaArtifact:
|
||||
_validate_identifier(artifact_id, "recorded media artifact id")
|
||||
matches = tuple(
|
||||
artifact
|
||||
for artifact in self.list_recorded_media(session_id)
|
||||
if artifact.artifact_id == artifact_id
|
||||
)
|
||||
if len(matches) != 1:
|
||||
raise SessionNotFoundError("recorded media source was not found")
|
||||
return matches[0]
|
||||
|
||||
def get_layout(self, workspace_id: str) -> WorkspaceLayout:
|
||||
_validate_identifier(workspace_id, "workspace id")
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM workspace_layouts WHERE workspace_id = ?",
|
||||
(workspace_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise SessionNotFoundError("workspace layout was not found")
|
||||
return _layout_from_row(row)
|
||||
|
||||
def save_layout(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
schema_version: int,
|
||||
expected_revision: int,
|
||||
name: str,
|
||||
layout: dict[str, Any],
|
||||
) -> WorkspaceLayout:
|
||||
_validate_identifier(workspace_id, "workspace id")
|
||||
if schema_version != 1:
|
||||
raise ValueError("only workspace layout schema version 1 is supported")
|
||||
if expected_revision < 0:
|
||||
raise ValueError("expected revision must be non-negative")
|
||||
normalized_name = name.strip()
|
||||
if not 1 <= len(normalized_name) <= 160:
|
||||
raise ValueError("layout name must contain 1..160 characters")
|
||||
serialized = _serialize_layout(layout)
|
||||
updated_at = utc_now_iso()
|
||||
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current = connection.execute(
|
||||
"SELECT revision FROM workspace_layouts WHERE workspace_id = ?",
|
||||
(workspace_id,),
|
||||
).fetchone()
|
||||
current_revision = 0 if current is None else int(current["revision"])
|
||||
if current_revision != expected_revision:
|
||||
connection.rollback()
|
||||
raise LayoutConflictError("workspace layout revision changed")
|
||||
revision = current_revision + 1
|
||||
connection.execute(
|
||||
"INSERT INTO workspace_layouts "
|
||||
"(workspace_id, layout_schema_version, revision, name, layout_json, "
|
||||
"updated_at_utc) VALUES (?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(workspace_id) DO UPDATE SET "
|
||||
"layout_schema_version = excluded.layout_schema_version, "
|
||||
"revision = excluded.revision, name = excluded.name, "
|
||||
"layout_json = excluded.layout_json, updated_at_utc = excluded.updated_at_utc",
|
||||
(
|
||||
workspace_id,
|
||||
schema_version,
|
||||
revision,
|
||||
normalized_name,
|
||||
serialized,
|
||||
updated_at,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return WorkspaceLayout(
|
||||
workspace_id=workspace_id,
|
||||
schema_version=schema_version,
|
||||
revision=revision,
|
||||
name=normalized_name,
|
||||
layout=json.loads(serialized),
|
||||
updated_at_utc=updated_at,
|
||||
)
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(SCHEMA_SQL)
|
||||
columns = {
|
||||
row["name"]
|
||||
for row in connection.execute("PRAGMA table_info(observation_sessions)")
|
||||
}
|
||||
if "replay_raw_bytes" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE observation_sessions "
|
||||
"ADD COLUMN replay_raw_bytes INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
if "replay_metadata_bytes" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE observation_sessions "
|
||||
"ADD COLUMN replay_metadata_bytes INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
connection.commit()
|
||||
with _ignore_os_error():
|
||||
self.database_path.chmod(0o600)
|
||||
|
||||
def _upsert_legacy(self, candidate: LegacySessionCandidate) -> None:
|
||||
_validate_identifier(candidate.session_id, "legacy session id")
|
||||
allowed_root = candidate.allowed_root.resolve()
|
||||
session_root = candidate.session_root.resolve()
|
||||
if not session_root.is_relative_to(allowed_root):
|
||||
raise SessionIntegrityError("legacy session root escapes its allowed root")
|
||||
sources = _legacy_sources(candidate)
|
||||
artifacts = _legacy_artifacts(candidate, session_root)
|
||||
now = utc_now_iso()
|
||||
modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":"))
|
||||
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT origin, allowed_root, session_root, created_at_utc "
|
||||
"FROM observation_sessions WHERE session_id = ?",
|
||||
(candidate.session_id,),
|
||||
).fetchone()
|
||||
if existing is not None and (
|
||||
existing["origin"] != "legacy-viewer-live"
|
||||
or Path(existing["allowed_root"]).resolve() != allowed_root
|
||||
or Path(existing["session_root"]).resolve() != session_root
|
||||
):
|
||||
connection.rollback()
|
||||
raise SessionIntegrityError("session id is already bound to another origin")
|
||||
created_at = existing["created_at_utc"] if existing is not None else now
|
||||
connection.execute(
|
||||
"INSERT INTO observation_sessions "
|
||||
"(session_id, display_name, status, started_at_utc, completed_at_utc, "
|
||||
"duration_seconds, modalities_json, replayable, origin, source_count, "
|
||||
"total_bytes, replay_raw_bytes, replay_metadata_bytes, allowed_root, "
|
||||
"session_root, created_at_utc, updated_at_utc) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(session_id) DO UPDATE SET "
|
||||
"display_name = excluded.display_name, status = excluded.status, "
|
||||
"started_at_utc = excluded.started_at_utc, "
|
||||
"completed_at_utc = excluded.completed_at_utc, "
|
||||
"duration_seconds = excluded.duration_seconds, "
|
||||
"modalities_json = excluded.modalities_json, "
|
||||
"replayable = excluded.replayable, source_count = excluded.source_count, "
|
||||
"total_bytes = excluded.total_bytes, "
|
||||
"replay_raw_bytes = excluded.replay_raw_bytes, "
|
||||
"replay_metadata_bytes = excluded.replay_metadata_bytes, "
|
||||
"updated_at_utc = excluded.updated_at_utc",
|
||||
(
|
||||
candidate.session_id,
|
||||
candidate.display_name,
|
||||
candidate.status,
|
||||
candidate.started_at_utc,
|
||||
candidate.completed_at_utc,
|
||||
candidate.duration_seconds,
|
||||
modalities_json,
|
||||
int(candidate.replayable),
|
||||
"legacy-viewer-live",
|
||||
len(sources),
|
||||
candidate.total_bytes,
|
||||
candidate.replay_raw_byte_length,
|
||||
candidate.replay_metadata_byte_length,
|
||||
str(allowed_root),
|
||||
str(session_root),
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM observation_session_sources WHERE session_id = ?",
|
||||
(candidate.session_id,),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM observation_session_artifacts WHERE session_id = ?",
|
||||
(candidate.session_id,),
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO observation_session_artifacts "
|
||||
"(session_id, artifact_id, kind, media_type, byte_length, sha256, "
|
||||
"integrity_status, locator) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
(candidate.session_id, *artifact)
|
||||
for artifact in artifacts
|
||||
],
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO observation_session_sources "
|
||||
"(session_id, source_id, semantic_channel_id, modality, status, seekable, "
|
||||
"artifact_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
(
|
||||
candidate.session_id,
|
||||
source.source_id,
|
||||
source.semantic_channel_id,
|
||||
source.modality,
|
||||
source.status,
|
||||
int(source.seekable),
|
||||
source.artifact_id,
|
||||
)
|
||||
for source in sources
|
||||
],
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
@contextmanager
|
||||
def _connect(self) -> Iterator[sqlite3.Connection]:
|
||||
connection = sqlite3.connect(self.database_path, timeout=5.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
connection.execute("PRAGMA synchronous = FULL")
|
||||
connection.execute("PRAGMA busy_timeout = 5000")
|
||||
connection.execute("PRAGMA journal_mode = WAL")
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def _legacy_sources(candidate: LegacySessionCandidate) -> tuple[SessionSource, ...]:
|
||||
rows: list[SessionSource] = []
|
||||
if "point-cloud" in candidate.modalities:
|
||||
rows.append(
|
||||
SessionSource(
|
||||
source_id="sensor.lidar.primary",
|
||||
semantic_channel_id="spatial.point-cloud.recorded",
|
||||
modality="point-cloud",
|
||||
status="recorded",
|
||||
seekable=candidate.replayable,
|
||||
artifact_id="raw-mqtt",
|
||||
)
|
||||
)
|
||||
if "trajectory" in candidate.modalities:
|
||||
rows.append(
|
||||
SessionSource(
|
||||
source_id="spatial.trajectory",
|
||||
semantic_channel_id="spatial.pose.recorded",
|
||||
modality="trajectory",
|
||||
status="recorded",
|
||||
seekable=candidate.replayable,
|
||||
artifact_id="raw-mqtt",
|
||||
)
|
||||
)
|
||||
rows.extend(
|
||||
SessionSource(
|
||||
source_id=media.source_id,
|
||||
semantic_channel_id="camera.video.recorded",
|
||||
modality="video",
|
||||
status="recorded",
|
||||
seekable=True,
|
||||
artifact_id=media.artifact_id,
|
||||
)
|
||||
for media in candidate.media_sources
|
||||
)
|
||||
if len({source.source_id for source in rows}) != len(rows):
|
||||
raise SessionIntegrityError("legacy session contains duplicate source identifiers")
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
def _legacy_artifacts(
|
||||
candidate: LegacySessionCandidate,
|
||||
session_root: Path,
|
||||
) -> tuple[tuple[str, str, str, int, str | None, str, str], ...]:
|
||||
artifacts: list[tuple[str, str, str, int, str | None, str, str]] = [
|
||||
(
|
||||
"raw-mqtt",
|
||||
"raw-transport",
|
||||
"application/x-nodedc-k1mqtt",
|
||||
candidate.raw_byte_length,
|
||||
candidate.raw_sha256,
|
||||
candidate.raw_integrity_status,
|
||||
str(candidate.raw_path),
|
||||
)
|
||||
]
|
||||
for media in candidate.media_sources:
|
||||
try:
|
||||
locator = media.locator.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("legacy video artifact is missing") from exc
|
||||
if not locator.is_dir() or not locator.is_relative_to(session_root):
|
||||
raise SessionIntegrityError("legacy video artifact escapes its session root")
|
||||
artifacts.append(
|
||||
(
|
||||
media.artifact_id,
|
||||
"recorded-video",
|
||||
"video/mp4",
|
||||
media.byte_length,
|
||||
None,
|
||||
"validated-structure",
|
||||
str(locator),
|
||||
)
|
||||
)
|
||||
if len({artifact[0] for artifact in artifacts}) != len(artifacts):
|
||||
raise SessionIntegrityError("legacy session contains duplicate artifact identifiers")
|
||||
return tuple(artifacts)
|
||||
|
||||
|
||||
def _summary_from_row(row: sqlite3.Row) -> SessionSummary:
|
||||
raw_modalities = json.loads(row["modalities_json"])
|
||||
modalities = tuple(cast(SessionModality, value) for value in raw_modalities)
|
||||
return SessionSummary(
|
||||
session_id=row["session_id"],
|
||||
display_name=row["display_name"],
|
||||
status=cast(SessionStatus, row["status"]),
|
||||
started_at_utc=row["started_at_utc"],
|
||||
completed_at_utc=row["completed_at_utc"],
|
||||
duration_seconds=row["duration_seconds"],
|
||||
modalities=modalities,
|
||||
source_count=row["source_count"],
|
||||
total_bytes=row["total_bytes"],
|
||||
replayable=bool(row["replayable"]),
|
||||
origin=row["origin"],
|
||||
)
|
||||
|
||||
|
||||
def _layout_from_row(row: sqlite3.Row) -> WorkspaceLayout:
|
||||
payload = json.loads(row["layout_json"])
|
||||
if not isinstance(payload, dict):
|
||||
raise SessionIntegrityError("stored workspace layout is not a JSON object")
|
||||
return WorkspaceLayout(
|
||||
workspace_id=row["workspace_id"],
|
||||
schema_version=row["layout_schema_version"],
|
||||
revision=row["revision"],
|
||||
name=row["name"],
|
||||
layout=payload,
|
||||
updated_at_utc=row["updated_at_utc"],
|
||||
)
|
||||
|
||||
|
||||
def _resolve_confined_artifact(
|
||||
allowed_root: Path,
|
||||
session_root: Path,
|
||||
locator: Path,
|
||||
) -> Path:
|
||||
try:
|
||||
allowed = allowed_root.resolve(strict=True)
|
||||
session = session_root.resolve(strict=True)
|
||||
artifact = locator.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("session replay artifact is missing") from exc
|
||||
if (
|
||||
not allowed.is_dir()
|
||||
or not session.is_dir()
|
||||
or not artifact.is_file()
|
||||
or not session.is_relative_to(allowed)
|
||||
or not artifact.is_relative_to(session)
|
||||
):
|
||||
raise SessionIntegrityError("session replay artifact escapes its allowed root")
|
||||
return artifact
|
||||
|
||||
|
||||
def _recorded_media_from_row(
|
||||
*,
|
||||
session_id: str,
|
||||
allowed_root: Path,
|
||||
session_root: Path,
|
||||
artifact_id: str,
|
||||
locator: Path,
|
||||
byte_length: int,
|
||||
) -> RecordedMediaArtifact:
|
||||
_validate_identifier(artifact_id, "recorded media artifact id")
|
||||
try:
|
||||
allowed = allowed_root.resolve(strict=True)
|
||||
session = session_root.resolve(strict=True)
|
||||
media_root = (session / "media").resolve(strict=True)
|
||||
artifact = locator.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("recorded media artifact is missing") from exc
|
||||
if (
|
||||
not allowed.is_dir()
|
||||
or not session.is_dir()
|
||||
or not media_root.is_dir()
|
||||
or not artifact.is_dir()
|
||||
or not session.is_relative_to(allowed)
|
||||
or not media_root.is_relative_to(session)
|
||||
or artifact.parent != media_root
|
||||
):
|
||||
raise SessionIntegrityError("recorded media artifact escapes its session root")
|
||||
if not isinstance(byte_length, int) or isinstance(byte_length, bool) or byte_length < 1:
|
||||
raise SessionIntegrityError("recorded media artifact has an invalid byte length")
|
||||
public_suffix = artifact_id.removeprefix("recorded-video-")
|
||||
public_source_id = f"recorded.camera.{public_suffix}"
|
||||
_validate_identifier(public_source_id, "recorded media source id")
|
||||
return RecordedMediaArtifact(
|
||||
session_id=session_id,
|
||||
public_source_id=public_source_id,
|
||||
artifact_id=artifact_id,
|
||||
source_path=artifact,
|
||||
byte_length=byte_length,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_layout(layout: dict[str, Any]) -> str:
|
||||
try:
|
||||
serialized = json.dumps(layout, ensure_ascii=False, separators=(",", ":"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("layout must be a JSON object") from exc
|
||||
if len(serialized.encode("utf-8")) > MAX_LAYOUT_BYTES:
|
||||
raise ValueError("layout exceeds the 256 KiB storage boundary")
|
||||
return serialized
|
||||
|
||||
|
||||
def _validate_identifier(value: str, label: str) -> None:
|
||||
if not IDENTIFIER_PATTERN.fullmatch(value):
|
||||
raise ValueError(f"{label} has an invalid shape")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _ignore_os_error() -> Iterator[None]:
|
||||
try:
|
||||
yield
|
||||
except OSError:
|
||||
return
|
||||
|
|
@ -53,11 +53,17 @@ def _iter_native_capture(path: Path) -> Iterator[StreamMessage]:
|
|||
epoch_ns = fallback_epoch_ns + frame.sequence - 1
|
||||
monotonic_ns: int | None = None
|
||||
if metadata_stream is not None:
|
||||
epoch_ns, monotonic_ns = _read_native_timing(
|
||||
timing = _read_native_timing(
|
||||
metadata_stream,
|
||||
expected_sequence=frame.sequence,
|
||||
fallback_epoch_ns=epoch_ns,
|
||||
)
|
||||
if timing is None:
|
||||
# A crash may leave one raw frame ahead of the last fully
|
||||
# committed metadata line. Only the aligned prefix has a
|
||||
# trustworthy source timeline and is safe to replay.
|
||||
return
|
||||
epoch_ns, monotonic_ns = timing
|
||||
yield StreamMessage(
|
||||
sequence=frame.sequence,
|
||||
topic=frame.topic,
|
||||
|
|
@ -78,15 +84,19 @@ def _read_native_timing(
|
|||
*,
|
||||
expected_sequence: int,
|
||||
fallback_epoch_ns: int,
|
||||
) -> tuple[int, int | None]:
|
||||
) -> tuple[int, int | None] | None:
|
||||
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
|
||||
if not line:
|
||||
return fallback_epoch_ns, None
|
||||
return None
|
||||
if len(line) > MAX_METADATA_LINE_CHARS:
|
||||
raise ReplayFormatError("native metadata line exceeds the reviewed bound")
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
if not line.endswith(("\n", "\r")):
|
||||
# A non-newline final tail is the only tolerated corruption: the
|
||||
# writer may have crashed between raw and metadata group commits.
|
||||
return None
|
||||
raise ReplayFormatError(
|
||||
f"native metadata line {expected_sequence} is not valid JSON"
|
||||
) from exc
|
||||
|
|
|
|||
|
|
@ -0,0 +1,595 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
from rerun import blueprint as rrb
|
||||
|
||||
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
|
||||
from k1link.protocol.normalizer import normalize_k1_message
|
||||
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
|
||||
from k1link.viewer.rerun_bridge import (
|
||||
MAX_TRAJECTORY_POSES,
|
||||
TRAJECTORY_APPEND_INTERVAL_NS,
|
||||
TRAJECTORY_FORCE_APPEND_NS,
|
||||
TRAJECTORY_MIN_DISTANCE_METERS,
|
||||
TRAJECTORY_PUBLISH_INTERVAL_NS,
|
||||
RerunSceneSettings,
|
||||
_parse_hex_color,
|
||||
_point_colors,
|
||||
)
|
||||
|
||||
APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
CAPTURE_TIMELINE = "capture_time"
|
||||
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
|
||||
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
|
||||
# blueprint update makes the update overwrite the existing scene instead of
|
||||
# creating a fresh view/container (which would also reset the operator's eye
|
||||
# position and layout).
|
||||
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
||||
|
||||
|
||||
class RrdExportSummary(TypedDict):
|
||||
schema_version: int
|
||||
input_path: str
|
||||
output_path: str
|
||||
recording_id: str
|
||||
timeline: str
|
||||
capture_timeline: str
|
||||
source_messages: int
|
||||
decoded_messages: int
|
||||
point_frames: int
|
||||
pose_frames: int
|
||||
ignored_messages: int
|
||||
points: int
|
||||
trajectory_poses: int
|
||||
trajectory_updates: int
|
||||
session_origin_monotonic_ns: int
|
||||
timeline_start_ns: int
|
||||
timeline_end_ns: int
|
||||
timeline_span_ns: int
|
||||
first_decoded_time_ns: int
|
||||
last_decoded_time_ns: int
|
||||
source_sha256: str
|
||||
rrd_sha256: str
|
||||
rrd_bytes: int
|
||||
|
||||
|
||||
class RrdExportError(RuntimeError):
|
||||
"""A raw capture could not be converted into a complete durable RRD."""
|
||||
|
||||
|
||||
class RrdExportCancelled(RrdExportError):
|
||||
"""A background RRD export was cooperatively cancelled."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ExportCounters:
|
||||
source_messages: int = 0
|
||||
point_frames: int = 0
|
||||
pose_frames: int = 0
|
||||
ignored_messages: int = 0
|
||||
points: int = 0
|
||||
first_decoded_time_ns: int | None = None
|
||||
last_decoded_time_ns: int | None = None
|
||||
|
||||
@property
|
||||
def decoded_messages(self) -> int:
|
||||
return self.point_frames + self.pose_frames
|
||||
|
||||
def observe_decoded(self, session_time_ns: int) -> None:
|
||||
if self.first_decoded_time_ns is None:
|
||||
self.first_decoded_time_ns = session_time_ns
|
||||
self.last_decoded_time_ns = session_time_ns
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _TrajectoryBuffer:
|
||||
positions: list[tuple[float, float, float]]
|
||||
last_append_time_ns: int | None = None
|
||||
last_publish_time_ns: int | None = None
|
||||
updates: int = 0
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> _TrajectoryBuffer:
|
||||
return cls(positions=[])
|
||||
|
||||
def process(
|
||||
self,
|
||||
recording: rr.RecordingStream,
|
||||
position: tuple[float, float, float],
|
||||
session_time_ns: int,
|
||||
) -> None:
|
||||
if not self._append(position, session_time_ns):
|
||||
return
|
||||
if (
|
||||
self.last_publish_time_ns is not None
|
||||
and session_time_ns - self.last_publish_time_ns < TRAJECTORY_PUBLISH_INTERVAL_NS
|
||||
):
|
||||
return
|
||||
self.last_publish_time_ns = session_time_ns
|
||||
self.updates += 1
|
||||
recording.log(
|
||||
"/world/trajectory",
|
||||
rr.LineStrips3D(
|
||||
[list(self.positions)],
|
||||
colors=[247, 248, 244, 255],
|
||||
radii=rr.Radius.ui_points(2.0),
|
||||
),
|
||||
)
|
||||
|
||||
def _append(self, position: tuple[float, float, float], session_time_ns: int) -> bool:
|
||||
if not self.positions:
|
||||
self.positions.append(position)
|
||||
self.last_append_time_ns = session_time_ns
|
||||
return True
|
||||
|
||||
assert self.last_append_time_ns is not None
|
||||
elapsed_ns = session_time_ns - self.last_append_time_ns
|
||||
if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
|
||||
return False
|
||||
if (
|
||||
math.dist(self.positions[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
|
||||
and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
|
||||
):
|
||||
return False
|
||||
|
||||
self.positions.append(position)
|
||||
self.last_append_time_ns = session_time_ns
|
||||
if len(self.positions) > MAX_TRAJECTORY_POSES:
|
||||
last = self.positions[-1]
|
||||
self.positions = self.positions[::2]
|
||||
if self.positions[-1] != last:
|
||||
self.positions.append(last)
|
||||
return True
|
||||
|
||||
|
||||
def export_k1mqtt_to_rrd(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: Callable[[], None] | None = None,
|
||||
) -> RrdExportSummary:
|
||||
"""Losslessly project every decodable K1 data-plane frame into one RRD.
|
||||
|
||||
The raw capture remains the source of record. The derived RRD uses a
|
||||
recording-local duration timeline whose zero is the first raw message's
|
||||
receive-monotonic timestamp. It never traverses the bounded live-preview
|
||||
queue, so export throughput cannot drop point or pose frames.
|
||||
|
||||
The destination is replaced only after the temporary RRD has been closed,
|
||||
flushed and fsynced. Any decode, timing, sink or rename failure therefore
|
||||
leaves an existing destination artifact untouched.
|
||||
"""
|
||||
|
||||
_raise_if_cancelled(cancel_event)
|
||||
source = input_path.expanduser().resolve()
|
||||
destination = output_path.expanduser().resolve()
|
||||
_validate_paths(source, destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recording_id = str(uuid4())
|
||||
temporary = destination.with_name(f".{destination.name}.{recording_id}.tmp")
|
||||
source_sha256 = _sha256_file(
|
||||
source,
|
||||
cancel_event=cancel_event,
|
||||
activity_callback=activity_callback,
|
||||
)
|
||||
settings = RerunSceneSettings()
|
||||
blueprint = _recorded_blueprint(settings)
|
||||
recording: rr.RecordingStream | None = None
|
||||
recording_closed = False
|
||||
published = False
|
||||
|
||||
counters = _ExportCounters()
|
||||
trajectory = _TrajectoryBuffer.empty()
|
||||
session_origin_ns: int | None = None
|
||||
previous_monotonic_ns: int | None = None
|
||||
|
||||
try:
|
||||
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
|
||||
recording.set_sinks(
|
||||
rr.FileSink(temporary, write_footer=True),
|
||||
default_blueprint=blueprint,
|
||||
)
|
||||
_log_static_scene(recording)
|
||||
_log_session_origin(recording)
|
||||
|
||||
for message in iter_replay_messages(source):
|
||||
_raise_if_cancelled(cancel_event)
|
||||
_notify_activity(activity_callback)
|
||||
counters.source_messages += 1
|
||||
monotonic_ns = message.received_monotonic_ns
|
||||
if monotonic_ns is None:
|
||||
raise RrdExportError(
|
||||
"native capture metadata must provide received_monotonic_ns "
|
||||
f"for message {message.sequence}"
|
||||
)
|
||||
if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
|
||||
raise RrdExportError(
|
||||
f"native capture monotonic time decreases at message {message.sequence}"
|
||||
)
|
||||
if session_origin_ns is None:
|
||||
session_origin_ns = monotonic_ns
|
||||
session_time_ns = monotonic_ns - session_origin_ns
|
||||
if session_time_ns > JS_MAX_SAFE_INTEGER:
|
||||
raise RrdExportError(
|
||||
"session duration exceeds the exact JavaScript nanosecond range"
|
||||
)
|
||||
previous_monotonic_ns = monotonic_ns
|
||||
|
||||
try:
|
||||
decoded = normalize_k1_message(
|
||||
message,
|
||||
processing_started_monotonic_ns=monotonic_ns,
|
||||
)
|
||||
except NormalizationError as exc:
|
||||
raise RrdExportError(
|
||||
f"known K1 frame {message.sequence} failed normalization"
|
||||
) from exc
|
||||
if decoded is None:
|
||||
counters.ignored_messages += 1
|
||||
continue
|
||||
|
||||
_set_frame_time(
|
||||
recording,
|
||||
decoded.context.sequence,
|
||||
session_time_ns,
|
||||
decoded.context.captured_at_epoch_ns,
|
||||
)
|
||||
counters.observe_decoded(session_time_ns)
|
||||
if isinstance(decoded, DecodedPointCloudView):
|
||||
_log_points(recording, decoded, settings)
|
||||
counters.point_frames += 1
|
||||
counters.points += decoded.point_count
|
||||
elif isinstance(decoded, DecodedPoseView):
|
||||
position = (
|
||||
float(decoded.position_xyz[0]),
|
||||
float(decoded.position_xyz[1]),
|
||||
float(decoded.position_xyz[2]),
|
||||
)
|
||||
_log_pose(recording, decoded, position)
|
||||
trajectory.process(recording, position, session_time_ns)
|
||||
counters.pose_frames += 1
|
||||
else:
|
||||
counters.ignored_messages += 1
|
||||
|
||||
if session_origin_ns is None:
|
||||
raise RrdExportError("native capture contains no messages")
|
||||
if counters.decoded_messages == 0:
|
||||
raise RrdExportError("native capture contains no decodable point or pose frames")
|
||||
assert counters.first_decoded_time_ns is not None
|
||||
assert counters.last_decoded_time_ns is not None
|
||||
|
||||
_raise_if_cancelled(cancel_event)
|
||||
recording.flush(timeout_sec=30.0)
|
||||
recording.disconnect()
|
||||
recording_closed = True
|
||||
_raise_if_cancelled(cancel_event)
|
||||
_fsync_file(temporary)
|
||||
rrd_bytes = temporary.stat().st_size
|
||||
if rrd_bytes <= 0:
|
||||
raise RrdExportError("Rerun produced an empty recording")
|
||||
rrd_sha256 = _sha256_file(
|
||||
temporary,
|
||||
cancel_event=cancel_event,
|
||||
activity_callback=activity_callback,
|
||||
)
|
||||
|
||||
summary = RrdExportSummary(
|
||||
schema_version=1,
|
||||
input_path=str(source),
|
||||
output_path=str(destination),
|
||||
recording_id=recording_id,
|
||||
timeline=SESSION_TIMELINE,
|
||||
capture_timeline=CAPTURE_TIMELINE,
|
||||
source_messages=counters.source_messages,
|
||||
decoded_messages=counters.decoded_messages,
|
||||
point_frames=counters.point_frames,
|
||||
pose_frames=counters.pose_frames,
|
||||
ignored_messages=counters.ignored_messages,
|
||||
points=counters.points,
|
||||
trajectory_poses=len(trajectory.positions),
|
||||
trajectory_updates=trajectory.updates,
|
||||
session_origin_monotonic_ns=session_origin_ns,
|
||||
timeline_start_ns=0,
|
||||
# Playback completeness is defined by data actually written to
|
||||
# the RRD. K1 status/heartbeat packets may continue long after the
|
||||
# final point or pose frame; advertising that raw tail as the RRD
|
||||
# end makes a strict browser buffering gate wait forever.
|
||||
timeline_end_ns=counters.last_decoded_time_ns,
|
||||
timeline_span_ns=counters.last_decoded_time_ns,
|
||||
first_decoded_time_ns=counters.first_decoded_time_ns,
|
||||
last_decoded_time_ns=counters.last_decoded_time_ns,
|
||||
source_sha256=source_sha256,
|
||||
rrd_sha256=rrd_sha256,
|
||||
rrd_bytes=rrd_bytes,
|
||||
)
|
||||
_raise_if_cancelled(cancel_event)
|
||||
os.replace(temporary, destination)
|
||||
_fsync_directory(destination.parent)
|
||||
published = True
|
||||
return summary
|
||||
except (ReplayFormatError, OSError) as exc:
|
||||
raise RrdExportError(f"RRD export failed: {exc}") from exc
|
||||
finally:
|
||||
if recording is not None and not recording_closed:
|
||||
with suppress(BaseException):
|
||||
recording.disconnect()
|
||||
if not published:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _validate_paths(source: Path, destination: Path) -> None:
|
||||
if not source.is_file():
|
||||
raise RrdExportError(f"raw capture does not exist: {source}")
|
||||
if source.suffix.casefold() != ".k1mqtt":
|
||||
raise RrdExportError("RRD export accepts only native .k1mqtt captures")
|
||||
if destination.suffix.casefold() != ".rrd":
|
||||
raise RrdExportError("RRD destination must use the .rrd suffix")
|
||||
if source == destination:
|
||||
raise RrdExportError("raw capture and RRD destination must be different files")
|
||||
try:
|
||||
replay_format = detect_replay_format(source)
|
||||
except ReplayFormatError as exc:
|
||||
raise RrdExportError(str(exc)) from exc
|
||||
if replay_format != "k1mqtt":
|
||||
raise RrdExportError("RRD export accepts only native K1MQTT captures")
|
||||
|
||||
|
||||
def _recorded_blueprint(
|
||||
settings: RerunSceneSettings,
|
||||
*,
|
||||
include_initial_playback_state: bool = True,
|
||||
) -> rrb.Blueprint:
|
||||
accumulation = max(0.0, settings.accumulation_seconds)
|
||||
# An omitted range means Rerun's native latest-at query: the most recent
|
||||
# LiDAR frame at the cursor. A zero-width range is *not* equivalent; it
|
||||
# only matches rows stamped at the cursor's exact nanosecond and therefore
|
||||
# makes an ordinary recorded scan appear empty.
|
||||
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||
if accumulation > 0:
|
||||
time_ranges = [
|
||||
rr.VisibleTimeRange(
|
||||
SESSION_TIMELINE,
|
||||
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
|
||||
end=rr.TimeRangeBoundary.cursor_relative(),
|
||||
)
|
||||
]
|
||||
point_visualizer = rr.Points3D.from_fields(
|
||||
# A negative Radius value is Rerun's serialized representation
|
||||
# for UI points. Blueprint overrides broadcast this singleton
|
||||
# value across every recorded point without rewriting the store.
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
# Recorded height/intensity/distance/RGB palettes are baked into
|
||||
# each Points3D row. A uniform custom color is the one color mode
|
||||
# that can be replaced safely by a singleton blueprint override.
|
||||
colors=(
|
||||
[_parse_hex_color(settings.custom_color)]
|
||||
if settings.palette == "custom"
|
||||
else None
|
||||
),
|
||||
).visualizer()
|
||||
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
|
||||
spatial_view = rrb.Spatial3DView(
|
||||
origin="/world",
|
||||
name="Пространственная сцена",
|
||||
background=[7, 8, 10, 255],
|
||||
line_grid=rrb.LineGrid3D(
|
||||
visible=settings.show_grid,
|
||||
color=[86, 91, 99, 110],
|
||||
stroke_width=0.75,
|
||||
),
|
||||
overrides={
|
||||
# EntityBehavior is evaluated from the blueprint store and can
|
||||
# therefore hide/reveal already-recorded entities without
|
||||
# rewriting the data RRD. Keep the point visualizer alongside it
|
||||
# so size/color overrides remain active for the same entity.
|
||||
"/world/points": [
|
||||
rrb.EntityBehavior(visible=settings.show_points),
|
||||
point_visualizer,
|
||||
],
|
||||
"/world/trajectory": rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
},
|
||||
# A positive window accumulates historical frames. With no
|
||||
# window, latest-at deliberately keeps one current LiDAR frame.
|
||||
time_ranges=time_ranges,
|
||||
)
|
||||
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
||||
root_container = rrb.Tabs(spatial_view)
|
||||
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
||||
|
||||
if include_initial_playback_state:
|
||||
# This state is appropriate only while opening a newly exported RRD.
|
||||
# Settings-only blueprint messages must not pause an already playing
|
||||
# recording or mutate the host-owned panel state.
|
||||
return rrb.Blueprint(
|
||||
root_container,
|
||||
rrb.TimePanel(
|
||||
timeline=SESSION_TIMELINE,
|
||||
play_state="paused",
|
||||
state="hidden",
|
||||
),
|
||||
auto_layout=False,
|
||||
auto_views=False,
|
||||
collapse_panels=True,
|
||||
)
|
||||
|
||||
return rrb.Blueprint(
|
||||
root_container,
|
||||
auto_layout=False,
|
||||
auto_views=False,
|
||||
collapse_panels=False,
|
||||
)
|
||||
|
||||
|
||||
def recorded_blueprint_rrd(
|
||||
settings: RerunSceneSettings,
|
||||
*,
|
||||
application_id: str = APPLICATION_ID,
|
||||
recording_id: str,
|
||||
) -> bytes:
|
||||
"""Serialize a small active blueprint update for an already-open recording.
|
||||
|
||||
The returned RRD contains blueprint-store messages only; it never copies the
|
||||
recorded data store and is therefore safe to push through a WebViewer log
|
||||
channel when operator display settings change.
|
||||
"""
|
||||
|
||||
recording = rr.RecordingStream(
|
||||
application_id,
|
||||
recording_id=recording_id,
|
||||
send_properties=False,
|
||||
)
|
||||
stream = rr.binary_stream(recording)
|
||||
try:
|
||||
recording.send_blueprint(
|
||||
_recorded_blueprint(
|
||||
settings,
|
||||
include_initial_playback_state=False,
|
||||
),
|
||||
make_active=True,
|
||||
make_default=False,
|
||||
)
|
||||
payload = stream.read(flush=True, flush_timeout_sec=5.0)
|
||||
except Exception as exc:
|
||||
raise RrdExportError("failed to serialize recorded blueprint") from exc
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
recording.disconnect()
|
||||
if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
|
||||
raise RrdExportError("serialized recorded blueprint is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _log_static_scene(recording: rr.RecordingStream) -> None:
|
||||
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||
recording.log(
|
||||
"/world/sensor_pose",
|
||||
rr.TransformAxes3D(axis_length=0.45, show_frame=False),
|
||||
static=True,
|
||||
)
|
||||
|
||||
|
||||
def _log_session_origin(recording: rr.RecordingStream) -> None:
|
||||
"""Materialize the declared zero of ``session_time`` outside the 3D scene."""
|
||||
|
||||
recording.set_time(
|
||||
SESSION_TIMELINE,
|
||||
duration=np.timedelta64(0, "ns"),
|
||||
)
|
||||
recording.log(
|
||||
"/__mission_core/session_origin",
|
||||
rr.AnyValues(session_origin=True),
|
||||
)
|
||||
|
||||
|
||||
def _set_frame_time(
|
||||
recording: rr.RecordingStream,
|
||||
sequence: int,
|
||||
session_time_ns: int,
|
||||
capture_time_ns: int,
|
||||
) -> None:
|
||||
recording.set_time(
|
||||
SESSION_TIMELINE,
|
||||
duration=np.timedelta64(session_time_ns, "ns"),
|
||||
)
|
||||
recording.set_time(
|
||||
CAPTURE_TIMELINE,
|
||||
timestamp=np.datetime64(capture_time_ns, "ns"),
|
||||
)
|
||||
recording.set_time("message_sequence", sequence=sequence)
|
||||
|
||||
|
||||
def _log_points(
|
||||
recording: rr.RecordingStream,
|
||||
frame: DecodedPointCloudView,
|
||||
settings: RerunSceneSettings,
|
||||
) -> None:
|
||||
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
|
||||
if frame.intensities is None:
|
||||
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
|
||||
else:
|
||||
intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
|
||||
rgb = (
|
||||
None
|
||||
if frame.colors_rgb is None
|
||||
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
||||
)
|
||||
recording.log(
|
||||
"/world/points",
|
||||
rr.Points3D(
|
||||
positions,
|
||||
colors=_point_colors(positions, intensities, rgb, settings),
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _log_pose(
|
||||
recording: rr.RecordingStream,
|
||||
frame: DecodedPoseView,
|
||||
position: tuple[float, float, float],
|
||||
) -> None:
|
||||
recording.log(
|
||||
"/world/sensor_pose",
|
||||
rr.Transform3D(
|
||||
translation=position,
|
||||
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _fsync_file(path: Path) -> None:
|
||||
with path.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _sha256_file(
|
||||
path: Path,
|
||||
*,
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: Callable[[], None] | None = None,
|
||||
) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
_raise_if_cancelled(cancel_event)
|
||||
_notify_activity(activity_callback)
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise RrdExportCancelled("RRD export was cancelled")
|
||||
|
||||
|
||||
def _notify_activity(callback: Callable[[], None] | None) -> None:
|
||||
if callback is not None:
|
||||
callback()
|
||||
|
|
@ -514,9 +514,9 @@ class VisualizationRuntime:
|
|||
callback()
|
||||
|
||||
|
||||
def new_live_session_dir(repository_root: Path) -> Path:
|
||||
def new_live_session_dir(sessions_root: Path) -> Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
base = repository_root / "sessions" / f"{stamp}_viewer_live"
|
||||
base = sessions_root / f"{stamp}_viewer_live"
|
||||
candidate = base
|
||||
suffix = 1
|
||||
while candidate.exists():
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -13,6 +13,19 @@ from fastapi.staticfiles import StaticFiles
|
|||
from pydantic import ValidationError
|
||||
|
||||
from k1link import __version__
|
||||
from k1link.sessions import (
|
||||
MaterializedRecording,
|
||||
RecordedMediaInspector,
|
||||
RecordedMediaManifest,
|
||||
RecordingPreparationQueueFull,
|
||||
ReplayCommand,
|
||||
SessionRecordingMaterializer,
|
||||
SessionRecordingPreparationManager,
|
||||
SessionStore,
|
||||
recover_stale_active_session_marker,
|
||||
resolve_missioncore_evidence_dir,
|
||||
)
|
||||
from k1link.web.camera_archive import recover_incomplete_camera_archives
|
||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
from k1link.web.plugin_runtime import (
|
||||
|
|
@ -23,21 +36,125 @@ from k1link.web.plugin_runtime import (
|
|||
PluginExecutionError,
|
||||
PluginNotFoundError,
|
||||
)
|
||||
from k1link.web.session_api import build_session_router
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
||||
|
||||
|
||||
legacy_observation_sessions_root = REPOSITORY_ROOT / "sessions"
|
||||
observation_sessions_root = resolve_missioncore_evidence_dir(REPOSITORY_ROOT)
|
||||
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||
session_store = SessionStore(REPOSITORY_ROOT)
|
||||
session_recording_materializer = SessionRecordingMaterializer(session_store.data_dir)
|
||||
session_recorded_media_inspector = RecordedMediaInspector(
|
||||
session_store.data_dir / "recorded-media-preparations"
|
||||
)
|
||||
|
||||
|
||||
def _prepare_recorded_media_for_launch(
|
||||
command: ReplayCommand,
|
||||
_: MaterializedRecording,
|
||||
) -> tuple[RecordedMediaManifest, ...]:
|
||||
"""Prepare all camera descriptors inside the background job."""
|
||||
|
||||
return tuple(
|
||||
session_recorded_media_inspector.inspect(artifact, command)
|
||||
for artifact in session_store.list_recorded_media(command.session_id)
|
||||
)
|
||||
|
||||
|
||||
session_recording_preparation_manager = SessionRecordingPreparationManager(
|
||||
session_recording_materializer,
|
||||
ready_preparer=_prepare_recorded_media_for_launch,
|
||||
)
|
||||
|
||||
|
||||
def refresh_observation_catalog() -> tuple[str, ...]:
|
||||
"""Discover completed or recoverable local evidence without copying payloads."""
|
||||
|
||||
imported = [
|
||||
*session_store.import_legacy_viewer_live(legacy_observation_sessions_root),
|
||||
*session_store.import_legacy_viewer_live(observation_sessions_root),
|
||||
]
|
||||
return tuple(dict.fromkeys(imported))
|
||||
|
||||
|
||||
def enqueue_replayable_recordings() -> tuple[str, ...]:
|
||||
"""Reconcile finalized catalog sessions into the durable RRD work queue."""
|
||||
|
||||
enqueued: list[str] = []
|
||||
cursor: str | None = None
|
||||
while True:
|
||||
page = session_store.list_recent(limit=100, cursor=cursor)
|
||||
for summary in page.items:
|
||||
if not summary.replayable or summary.status not in {
|
||||
"ready",
|
||||
"interrupted",
|
||||
"failed",
|
||||
}:
|
||||
continue
|
||||
try:
|
||||
command = session_store.prepare_replay(summary.session_id)
|
||||
session_recording_preparation_manager.enqueue(
|
||||
command,
|
||||
retry_interrupted=True,
|
||||
)
|
||||
except RecordingPreparationQueueFull:
|
||||
return tuple(enqueued)
|
||||
except Exception:
|
||||
# One stale/corrupt catalog row must not starve every valid
|
||||
# finalized session that follows it in the reconciliation
|
||||
# page. The row remains visible as failed/interrupted evidence
|
||||
# and can be repaired independently.
|
||||
continue
|
||||
enqueued.append(summary.session_id)
|
||||
cursor = page.next_cursor
|
||||
if cursor is None:
|
||||
return tuple(enqueued)
|
||||
|
||||
|
||||
async def _recording_preparation_reconciler() -> None:
|
||||
"""Discover newly completed/recovered captures without blocking requests."""
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(refresh_observation_catalog)
|
||||
await asyncio.to_thread(enqueue_replayable_recordings)
|
||||
except Exception:
|
||||
# A transient filesystem/catalog failure must not permanently
|
||||
# disable preparation of sessions completed later in the run.
|
||||
pass
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
reconciler: asyncio.Task[None] | None = None
|
||||
try:
|
||||
session_recording_preparation_manager.start()
|
||||
# Recovery is intentionally a one-shot startup phase. The archive
|
||||
# helper owns a cross-process lease, while ordinary catalog requests
|
||||
# only perform discovery and therefore never touch a live writer.
|
||||
await asyncio.to_thread(recover_stale_active_session_marker, observation_sessions_root)
|
||||
for sessions_root in (
|
||||
legacy_observation_sessions_root,
|
||||
observation_sessions_root,
|
||||
):
|
||||
await asyncio.to_thread(recover_incomplete_camera_archives, sessions_root)
|
||||
# Full evidence discovery, hashing and RRD queue reconciliation can be
|
||||
# expensive on field captures. Start it immediately in the background
|
||||
# instead of holding the ASGI startup gate.
|
||||
reconciler = asyncio.create_task(_recording_preparation_reconciler())
|
||||
yield
|
||||
finally:
|
||||
if reconciler is not None:
|
||||
reconciler.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await reconciler
|
||||
await asyncio.to_thread(session_recording_preparation_manager.close)
|
||||
plugin_environment.close()
|
||||
|
||||
|
||||
|
|
@ -127,6 +244,18 @@ async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
|
|||
for legacy_router in plugin_environment.legacy_routers:
|
||||
app.include_router(legacy_router)
|
||||
|
||||
app.include_router(
|
||||
build_session_router(
|
||||
session_store,
|
||||
# Production discovery belongs to the startup/background reconciler.
|
||||
# HTTP list/replay paths must never rescan evidence roots inline.
|
||||
catalog_refresher=None,
|
||||
recording_materializer=session_recording_materializer,
|
||||
recording_preparation_manager=session_recording_preparation_manager,
|
||||
media_inspector=session_recorded_media_inspector,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||
if frontend_dist.is_dir():
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -15,6 +15,12 @@ from typing import IO, Any, Final, Literal
|
|||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from k1link.mqtt import validate_private_ipv4
|
||||
from k1link.web.camera_archive import (
|
||||
CameraArchiveError,
|
||||
CameraArchiveKind,
|
||||
CameraArchiveStatus,
|
||||
CameraArchiveWriter,
|
||||
)
|
||||
|
||||
CameraSourceId = Literal["sensor.camera.left", "sensor.camera.right"]
|
||||
|
||||
|
|
@ -31,6 +37,7 @@ CAMERA_EXCLUSIVE_GROUP: Final = "camera.preview.decoder"
|
|||
MAX_FMP4_BOX_BYTES: Final = 8 * 1024 * 1024
|
||||
MAX_FMP4_SEGMENT_BYTES: Final = 1024 * 1024
|
||||
MAX_QUEUED_SEGMENTS: Final = 4
|
||||
CAMERA_DRAIN_TIMEOUT_SECONDS: Final = 5.0
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -38,34 +45,52 @@ class CameraProcessLease:
|
|||
generation: int
|
||||
source_id: CameraSourceId
|
||||
process: subprocess.Popen[bytes]
|
||||
stderr_tail: deque[str] = field(default_factory=lambda: deque(maxlen=12))
|
||||
stderr_tail: deque[str]
|
||||
segments: queue.Queue[tuple[str, bytes] | None] = field(
|
||||
default_factory=lambda: queue.Queue(maxsize=MAX_QUEUED_SEGMENTS)
|
||||
)
|
||||
failure_code: str | None = None
|
||||
|
||||
|
||||
class XgridsK1CameraGateway:
|
||||
"""One fail-closed K1 RTSP owner with a browser-safe fMP4 delivery plane.
|
||||
@dataclass
|
||||
class _CameraProducer:
|
||||
generation: int
|
||||
source_id: CameraSourceId
|
||||
process: subprocess.Popen[bytes]
|
||||
archive: CameraArchiveWriter | None
|
||||
stderr_tail: deque[str] = field(default_factory=lambda: deque(maxlen=12))
|
||||
delivery: CameraProcessLease | None = None
|
||||
init_segment: bytes | None = None
|
||||
failure_code: str | None = None
|
||||
stop_requested: bool = False
|
||||
drain_requested: bool = False
|
||||
reader_started: bool = False
|
||||
reader_done: threading.Event = field(default_factory=threading.Event)
|
||||
|
||||
Selection and delivery are deliberately separate. A plugin action selects
|
||||
one allowlisted producer and publishes an opaque generation. Only a matching
|
||||
WebSocket may then start FFmpeg. This keeps the device address and RTSP path
|
||||
out of the generic UI and prevents two browser windows from opening two K1
|
||||
sessions.
|
||||
|
||||
class XgridsK1CameraGateway:
|
||||
"""One fail-closed K1 producer with independent archive and preview planes.
|
||||
|
||||
During an acquisition the gateway, rather than a browser WebSocket, owns
|
||||
FFmpeg. Every complete fMP4 segment is durably appended before it can enter
|
||||
the bounded preview queue. Attaching, dropping, or disconnecting a browser
|
||||
therefore cannot stop or back-pressure the source-of-record camera stream.
|
||||
Outside an acquisition the legacy lazy-preview lifecycle remains available.
|
||||
"""
|
||||
|
||||
def __init__(self, repository_root: Path, plugin_id: str) -> None:
|
||||
self._repository_root = repository_root.resolve()
|
||||
self._plugin_id = plugin_id
|
||||
self._lock = threading.RLock()
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._revision = 0
|
||||
self._generation = 0
|
||||
self._phase = "idle"
|
||||
self._source_id: CameraSourceId | None = None
|
||||
self._target_host: str | None = None
|
||||
self._process: subprocess.Popen[bytes] | None = None
|
||||
self._process_generation: int | None = None
|
||||
self._producer: _CameraProducer | None = None
|
||||
self._recording_root: Path | None = None
|
||||
self._archive_summaries: list[dict[str, Any]] = []
|
||||
self._error: dict[str, str] | None = None
|
||||
self._closed = False
|
||||
self._ffmpeg_path, self._ffmpeg_source = _resolve_ffmpeg(self._repository_root)
|
||||
|
|
@ -97,6 +122,21 @@ class XgridsK1CameraGateway:
|
|||
"exclusive_group": CAMERA_EXCLUSIVE_GROUP,
|
||||
"max_active": 1,
|
||||
},
|
||||
"recording": {
|
||||
"active": self._recording_root is not None,
|
||||
"session": (
|
||||
self._recording_root.name if self._recording_root is not None else None
|
||||
),
|
||||
"active_epoch": (
|
||||
self._producer.generation
|
||||
if self._producer is not None and self._producer.archive is not None
|
||||
else None
|
||||
),
|
||||
"completed_epochs": len(self._archive_summaries),
|
||||
"last_summary": (
|
||||
dict(self._archive_summaries[-1]) if self._archive_summaries else None
|
||||
),
|
||||
},
|
||||
"delivery": delivery,
|
||||
"runtime_dependency": {
|
||||
"kind": "ffmpeg",
|
||||
|
|
@ -110,192 +150,537 @@ class XgridsK1CameraGateway:
|
|||
if source_id not in CAMERA_SOURCE_PATHS:
|
||||
raise ValueError("неизвестный camera source")
|
||||
target = validate_private_ipv4(target_host)
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
self._require_open_locked()
|
||||
if self._ffmpeg_path is None:
|
||||
self._generation += 1
|
||||
self._revision += 1
|
||||
self._source_id = source_id
|
||||
self._target_host = target
|
||||
self._set_error_locked(
|
||||
"ffmpeg-unavailable",
|
||||
"Локальный camera adapter FFmpeg не найден.",
|
||||
)
|
||||
raise RuntimeError("локальный camera adapter FFmpeg не найден")
|
||||
if (
|
||||
self._source_id == source_id
|
||||
and self._target_host == target
|
||||
and self._phase in {"selected", "connecting", "streaming"}
|
||||
):
|
||||
return self.snapshot()
|
||||
|
||||
old_process: subprocess.Popen[bytes] | None = None
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("camera gateway уже закрыт")
|
||||
if self._ffmpeg_path is None:
|
||||
old_producer, old_delivery = self._detach_producer_locked()
|
||||
self._generation += 1
|
||||
self._revision += 1
|
||||
self._source_id = source_id
|
||||
self._target_host = target
|
||||
self._phase = "error"
|
||||
self._error = {
|
||||
"code": "ffmpeg-unavailable",
|
||||
"message": "Локальный camera adapter FFmpeg не найден.",
|
||||
}
|
||||
raise RuntimeError("локальный camera adapter FFmpeg не найден")
|
||||
if (
|
||||
self._source_id == source_id
|
||||
and self._target_host == target
|
||||
and self._phase in {"selected", "connecting", "streaming"}
|
||||
):
|
||||
return self.snapshot()
|
||||
self._phase = "selected"
|
||||
self._error = None
|
||||
recording_active = self._recording_root is not None
|
||||
|
||||
old_process = self._detach_process_locked()
|
||||
self._generation += 1
|
||||
self._revision += 1
|
||||
self._source_id = source_id
|
||||
self._target_host = target
|
||||
self._phase = "selected"
|
||||
self._error = None
|
||||
|
||||
_terminate_process(old_process)
|
||||
return self.snapshot()
|
||||
self._shutdown_producer(
|
||||
old_producer,
|
||||
old_delivery,
|
||||
status="complete",
|
||||
failure_code="source-switch",
|
||||
)
|
||||
if recording_active:
|
||||
self._spawn_selected_producer()
|
||||
return self.snapshot()
|
||||
|
||||
def stop(self, generation: int) -> dict[str, Any]:
|
||||
old_process: subprocess.Popen[bytes] | None
|
||||
with self._lock:
|
||||
if self._source_id is None:
|
||||
return self.snapshot()
|
||||
if generation != self._generation:
|
||||
raise ValueError("camera preview generation устарело")
|
||||
old_process = self._detach_process_locked()
|
||||
self._revision += 1
|
||||
self._phase = "idle"
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._error = None
|
||||
_terminate_process(old_process)
|
||||
return self.snapshot()
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
if self._source_id is None:
|
||||
return self.snapshot()
|
||||
if generation != self._generation:
|
||||
raise ValueError("camera preview generation устарело")
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
self._revision += 1
|
||||
self._phase = "idle"
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._error = None
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
delivery,
|
||||
status="complete",
|
||||
failure_code="source-stopped",
|
||||
)
|
||||
return self.snapshot()
|
||||
|
||||
def stop_current(self) -> dict[str, Any]:
|
||||
old_process: subprocess.Popen[bytes] | None
|
||||
with self._lock:
|
||||
old_process = self._detach_process_locked()
|
||||
changed = self._source_id is not None or self._phase != "idle"
|
||||
if changed:
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
changed = self._source_id is not None or self._phase != "idle"
|
||||
if changed:
|
||||
self._revision += 1
|
||||
self._phase = "idle"
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._recording_root = None
|
||||
self._error = None
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
delivery,
|
||||
status="interrupted",
|
||||
failure_code="gateway-stop",
|
||||
)
|
||||
return self.snapshot()
|
||||
|
||||
def start_recording(self, session_dir: Path) -> dict[str, Any]:
|
||||
"""Make an existing observation session the camera recording root."""
|
||||
|
||||
root = session_dir.expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
raise ValueError("observation session directory does not exist")
|
||||
if not root.is_relative_to(self._repository_root):
|
||||
raise ValueError("camera recording root must stay inside the repository")
|
||||
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
self._require_open_locked()
|
||||
if self._recording_root is not None:
|
||||
if self._recording_root == root:
|
||||
return self.snapshot()
|
||||
raise RuntimeError("для camera gateway уже активна другая acquisition-сессия")
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
self._recording_root = root
|
||||
self._archive_summaries = []
|
||||
selected = self._source_id is not None
|
||||
if selected:
|
||||
self._phase = "selected"
|
||||
self._revision += 1
|
||||
self._phase = "idle"
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._error = None
|
||||
_terminate_process(old_process)
|
||||
return self.snapshot()
|
||||
|
||||
# A pre-acquisition browser-owned process cannot become evidence
|
||||
# retrospectively. Restart it at a clean codec epoch instead.
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
delivery,
|
||||
status="interrupted",
|
||||
failure_code="recording-start-restart",
|
||||
)
|
||||
if selected:
|
||||
self._spawn_selected_producer()
|
||||
return self.snapshot()
|
||||
|
||||
def stop_recording(
|
||||
self,
|
||||
*,
|
||||
status: CameraArchiveStatus = "complete",
|
||||
failure_code: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Stop the acquisition-owned producer and seal its active epoch."""
|
||||
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
recording_was_active = self._recording_root is not None
|
||||
self._recording_root = None
|
||||
if self._source_id is not None and self._phase != "error":
|
||||
self._phase = "selected"
|
||||
if recording_was_active or producer is not None:
|
||||
self._revision += 1
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
delivery,
|
||||
status=status,
|
||||
failure_code=failure_code,
|
||||
)
|
||||
return self.snapshot()
|
||||
|
||||
def open_delivery(self, generation: int) -> CameraProcessLease:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("camera gateway закрыт")
|
||||
if generation != self._generation or self._source_id is None:
|
||||
raise ValueError("camera preview generation не активно")
|
||||
if self._process is not None:
|
||||
raise RuntimeError("для camera preview уже открыт browser consumer")
|
||||
if self._ffmpeg_path is None or self._target_host is None:
|
||||
raise RuntimeError("camera preview runtime не готов")
|
||||
|
||||
argv = _build_ffmpeg_argv(
|
||||
self._ffmpeg_path,
|
||||
self._target_host,
|
||||
self._source_id,
|
||||
)
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
argv,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
shell=False,
|
||||
start_new_session=(os.name == "posix"),
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
self._require_open_locked()
|
||||
if generation != self._generation or self._source_id is None:
|
||||
raise ValueError("camera preview generation не активно")
|
||||
producer = self._producer
|
||||
if producer is None:
|
||||
producer = self._spawn_selected_producer()
|
||||
with self._lock:
|
||||
if self._producer is not producer or producer.generation != generation:
|
||||
raise ValueError("camera preview generation не активно")
|
||||
if producer.delivery is not None:
|
||||
raise RuntimeError("для camera preview уже открыт browser consumer")
|
||||
lease = CameraProcessLease(
|
||||
generation=producer.generation,
|
||||
source_id=producer.source_id,
|
||||
process=producer.process,
|
||||
stderr_tail=producer.stderr_tail,
|
||||
)
|
||||
except OSError as exc:
|
||||
producer.delivery = lease
|
||||
if producer.init_segment is not None:
|
||||
lease.segments.put_nowait(("init", producer.init_segment))
|
||||
self._revision += 1
|
||||
self._phase = "error"
|
||||
self._error = {
|
||||
"code": "ffmpeg-start-failed",
|
||||
"message": "Не удалось запустить локальный camera adapter.",
|
||||
}
|
||||
raise RuntimeError("не удалось запустить camera adapter") from exc
|
||||
|
||||
if process.stdout is None or process.stderr is None:
|
||||
_terminate_process(process)
|
||||
raise RuntimeError("camera adapter не открыл media pipes")
|
||||
lease = CameraProcessLease(
|
||||
generation=generation,
|
||||
source_id=self._source_id,
|
||||
process=process,
|
||||
)
|
||||
self._process = process
|
||||
self._process_generation = generation
|
||||
self._revision += 1
|
||||
self._phase = "connecting"
|
||||
self._error = None
|
||||
|
||||
threading.Thread(
|
||||
target=_drain_stderr,
|
||||
args=(lease,),
|
||||
name=f"k1-camera-stderr-{generation}",
|
||||
daemon=True,
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=_read_fmp4_stdout,
|
||||
args=(lease,),
|
||||
name=f"k1-camera-fmp4-{generation}",
|
||||
daemon=True,
|
||||
).start()
|
||||
return lease
|
||||
return lease
|
||||
|
||||
def mark_streaming(self, lease: CameraProcessLease) -> None:
|
||||
with self._lock:
|
||||
if (
|
||||
lease.generation != self._generation
|
||||
or self._process is not lease.process
|
||||
or self._phase == "streaming"
|
||||
):
|
||||
producer = self._producer
|
||||
if producer is None or producer.delivery is not lease:
|
||||
return
|
||||
self._mark_streaming_locked(producer)
|
||||
|
||||
def release_delivery(self, lease: CameraProcessLease, *, client_closed: bool) -> None:
|
||||
producer_to_stop: _CameraProducer | None = None
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
producer = self._producer
|
||||
if producer is None or producer.delivery is not lease:
|
||||
return
|
||||
producer.delivery = None
|
||||
self._revision += 1
|
||||
# During acquisition the browser is a disposable observer. In
|
||||
# legacy preview-only mode retain the old lazy-owner behavior.
|
||||
if self._recording_root is None:
|
||||
producer.stop_requested = True
|
||||
self._producer = None
|
||||
producer_to_stop = producer
|
||||
self._phase = "selected"
|
||||
self._error = None
|
||||
if producer_to_stop is not None:
|
||||
self._shutdown_producer(
|
||||
producer_to_stop,
|
||||
None,
|
||||
status="interrupted",
|
||||
failure_code=(lease.failure_code or "browser-disconnected"),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
self._revision += 1
|
||||
self._phase = "idle"
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._recording_root = None
|
||||
self._error = None
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
delivery,
|
||||
status="interrupted",
|
||||
failure_code="gateway-closed",
|
||||
)
|
||||
|
||||
def _spawn_selected_producer(self) -> _CameraProducer:
|
||||
with self._lock:
|
||||
self._require_open_locked()
|
||||
source_id = self._source_id
|
||||
target_host = self._target_host
|
||||
ffmpeg_path = self._ffmpeg_path
|
||||
generation = self._generation
|
||||
recording_root = self._recording_root
|
||||
if source_id is None or target_host is None or ffmpeg_path is None:
|
||||
raise RuntimeError("camera preview runtime не готов")
|
||||
if self._producer is not None:
|
||||
return self._producer
|
||||
|
||||
archive: CameraArchiveWriter | None = None
|
||||
if recording_root is not None:
|
||||
try:
|
||||
archive = CameraArchiveWriter(recording_root, source_id, generation)
|
||||
except (OSError, ValueError, CameraArchiveError) as exc:
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"camera-storage-failed",
|
||||
"Не удалось открыть долговременное хранилище camera stream.",
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Не удалось открыть долговременное хранилище camera stream."
|
||||
) from exc
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
_build_ffmpeg_argv(ffmpeg_path, target_host, source_id),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
shell=False,
|
||||
start_new_session=(os.name == "posix"),
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
if archive is not None:
|
||||
with suppress(CameraArchiveError):
|
||||
self._record_archive_summary(
|
||||
archive.close(status="failed", failure_code="ffmpeg-start-failed")
|
||||
)
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"ffmpeg-start-failed",
|
||||
"Не удалось запустить локальный camera adapter.",
|
||||
)
|
||||
raise RuntimeError("Не удалось запустить локальный camera adapter.") from exc
|
||||
|
||||
if process.stdout is None or process.stderr is None:
|
||||
_terminate_process(process)
|
||||
if archive is not None:
|
||||
self._record_archive_summary(
|
||||
archive.close(status="failed", failure_code="ffmpeg-pipes-unavailable")
|
||||
)
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"ffmpeg-pipes-unavailable",
|
||||
"Camera adapter не открыл media pipes.",
|
||||
)
|
||||
raise RuntimeError("camera adapter не открыл media pipes")
|
||||
|
||||
producer = _CameraProducer(
|
||||
generation=generation,
|
||||
source_id=source_id,
|
||||
process=process,
|
||||
archive=archive,
|
||||
)
|
||||
with self._lock:
|
||||
if (
|
||||
self._closed
|
||||
or generation != self._generation
|
||||
or source_id != self._source_id
|
||||
or target_host != self._target_host
|
||||
):
|
||||
producer.stop_requested = True
|
||||
stale = True
|
||||
else:
|
||||
self._producer = producer
|
||||
self._revision += 1
|
||||
self._phase = "connecting"
|
||||
self._error = None
|
||||
stale = False
|
||||
if stale:
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
None,
|
||||
status="interrupted",
|
||||
failure_code="stale-generation",
|
||||
)
|
||||
raise RuntimeError("camera generation изменилась во время запуска adapter")
|
||||
|
||||
threading.Thread(
|
||||
target=_drain_stderr,
|
||||
args=(producer,),
|
||||
name=f"k1-camera-stderr-{generation}",
|
||||
daemon=True,
|
||||
).start()
|
||||
producer.reader_started = True
|
||||
threading.Thread(
|
||||
target=_read_fmp4_stdout,
|
||||
args=(self, producer),
|
||||
name=f"k1-camera-fmp4-{generation}",
|
||||
daemon=True,
|
||||
).start()
|
||||
return producer
|
||||
|
||||
def _publish_segment(
|
||||
self,
|
||||
producer: _CameraProducer,
|
||||
kind: CameraArchiveKind,
|
||||
payload: bytes,
|
||||
) -> bool:
|
||||
if len(payload) > MAX_FMP4_SEGMENT_BYTES:
|
||||
self._mark_producer_failure(
|
||||
producer,
|
||||
"segment-too-large",
|
||||
"Camera adapter отклонил слишком большой video segment.",
|
||||
)
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
producer_owned = self._producer is producer or (
|
||||
producer.drain_requested and producer.archive is not None
|
||||
)
|
||||
if not producer_owned or producer.stop_requested:
|
||||
return False
|
||||
archive = producer.archive
|
||||
if archive is not None:
|
||||
try:
|
||||
# Source of record first; preview is always expendable.
|
||||
archive.append(kind, payload)
|
||||
except (CameraArchiveError, OSError, ValueError):
|
||||
self._mark_producer_failure(
|
||||
producer,
|
||||
"camera-storage-failed",
|
||||
"Долговременная запись camera stream завершилась ошибкой.",
|
||||
)
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
producer_owned = self._producer is producer or (
|
||||
producer.drain_requested and producer.archive is not None
|
||||
)
|
||||
if not producer_owned or producer.stop_requested:
|
||||
return False
|
||||
if kind == "init":
|
||||
producer.init_segment = payload
|
||||
else:
|
||||
self._mark_streaming_locked(producer)
|
||||
delivery = producer.delivery
|
||||
if delivery is None:
|
||||
return True
|
||||
try:
|
||||
delivery.segments.put_nowait((kind, payload))
|
||||
except queue.Full:
|
||||
self._drop_slow_delivery(producer, delivery)
|
||||
return True
|
||||
|
||||
def _drop_slow_delivery(
|
||||
self,
|
||||
producer: _CameraProducer,
|
||||
delivery: CameraProcessLease,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if self._producer is producer and producer.delivery is delivery:
|
||||
producer.delivery = None
|
||||
delivery.failure_code = "consumer-too-slow"
|
||||
self._revision += 1
|
||||
_close_segment_queue(delivery)
|
||||
|
||||
def _mark_producer_failure(
|
||||
self,
|
||||
producer: _CameraProducer,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
producer_owned = self._producer is producer or producer.drain_requested
|
||||
if not producer_owned or producer.stop_requested:
|
||||
return
|
||||
producer.failure_code = code
|
||||
self._set_error_locked(code, message)
|
||||
with suppress(OSError):
|
||||
producer.process.terminate()
|
||||
|
||||
def _producer_ended(self, producer: _CameraProducer) -> None:
|
||||
with self._lock:
|
||||
delivery = producer.delivery
|
||||
producer.delivery = None
|
||||
owns_shutdown = self._producer is producer and not producer.stop_requested
|
||||
if owns_shutdown:
|
||||
self._producer = None
|
||||
self._revision += 1
|
||||
if producer.failure_code is None:
|
||||
producer.failure_code = "camera-source-ended"
|
||||
self._set_error_locked(
|
||||
"camera-source-ended",
|
||||
_safe_ffmpeg_message(producer.stderr_tail),
|
||||
)
|
||||
if delivery is not None:
|
||||
delivery.failure_code = producer.failure_code or "camera-source-ended"
|
||||
_close_segment_queue(delivery)
|
||||
if not owns_shutdown:
|
||||
return
|
||||
_terminate_process(producer.process)
|
||||
status: CameraArchiveStatus = (
|
||||
"interrupted"
|
||||
if producer.failure_code
|
||||
in {"camera-source-ended", "incomplete-fmp4-fragment"}
|
||||
else "failed"
|
||||
)
|
||||
try:
|
||||
self._finalize_archive(producer, status, producer.failure_code)
|
||||
except CameraArchiveError:
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"camera-storage-finalize-failed",
|
||||
"Не удалось завершить долговременную запись camera stream.",
|
||||
)
|
||||
|
||||
def _shutdown_producer(
|
||||
self,
|
||||
producer: _CameraProducer | None,
|
||||
delivery: CameraProcessLease | None,
|
||||
*,
|
||||
status: CameraArchiveStatus,
|
||||
failure_code: str | None,
|
||||
) -> None:
|
||||
if delivery is not None:
|
||||
delivery.failure_code = producer.failure_code if producer is not None else failure_code
|
||||
_close_segment_queue(delivery)
|
||||
if producer is None:
|
||||
return
|
||||
_terminate_process(producer.process, close_streams=False)
|
||||
drained = (
|
||||
not producer.reader_started
|
||||
or producer.reader_done.wait(timeout=CAMERA_DRAIN_TIMEOUT_SECONDS)
|
||||
)
|
||||
_close_process_streams(producer.process)
|
||||
if not drained:
|
||||
producer.failure_code = "camera-drain-timeout"
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"camera-drain-timeout",
|
||||
"Camera adapter не завершил долговременную запись вовремя.",
|
||||
)
|
||||
effective_failure_code = producer.failure_code or failure_code
|
||||
effective_status: CameraArchiveStatus = (
|
||||
"interrupted"
|
||||
if producer.failure_code == "incomplete-fmp4-fragment"
|
||||
else "failed"
|
||||
if producer.failure_code is not None
|
||||
else status
|
||||
)
|
||||
if delivery is not None:
|
||||
delivery.failure_code = effective_failure_code
|
||||
try:
|
||||
self._finalize_archive(
|
||||
producer,
|
||||
effective_status,
|
||||
effective_failure_code,
|
||||
)
|
||||
except CameraArchiveError:
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"camera-storage-finalize-failed",
|
||||
"Не удалось завершить долговременную запись camera stream.",
|
||||
)
|
||||
raise
|
||||
|
||||
def _finalize_archive(
|
||||
self,
|
||||
producer: _CameraProducer,
|
||||
status: CameraArchiveStatus,
|
||||
failure_code: str | None,
|
||||
) -> None:
|
||||
archive = producer.archive
|
||||
producer.archive = None
|
||||
if archive is None:
|
||||
return
|
||||
self._record_archive_summary(archive.close(status=status, failure_code=failure_code))
|
||||
|
||||
def _record_archive_summary(self, summary: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self._archive_summaries.append(dict(summary))
|
||||
|
||||
def _detach_producer_locked(
|
||||
self,
|
||||
) -> tuple[_CameraProducer | None, CameraProcessLease | None]:
|
||||
producer = self._producer
|
||||
self._producer = None
|
||||
if producer is None:
|
||||
return None, None
|
||||
producer.drain_requested = producer.archive is not None
|
||||
producer.stop_requested = not producer.drain_requested
|
||||
delivery = producer.delivery
|
||||
producer.delivery = None
|
||||
return producer, delivery
|
||||
|
||||
def _mark_streaming_locked(self, producer: _CameraProducer) -> None:
|
||||
if self._producer is producer and self._phase != "streaming":
|
||||
self._revision += 1
|
||||
self._phase = "streaming"
|
||||
|
||||
def release_delivery(self, lease: CameraProcessLease, *, client_closed: bool) -> None:
|
||||
_terminate_process(lease.process)
|
||||
with self._lock:
|
||||
if self._process is lease.process:
|
||||
self._process = None
|
||||
self._process_generation = None
|
||||
if lease.generation != self._generation or self._source_id is None:
|
||||
return
|
||||
self._revision += 1
|
||||
if client_closed:
|
||||
self._phase = "selected"
|
||||
self._error = None
|
||||
else:
|
||||
self._phase = "error"
|
||||
if lease.failure_code == "consumer-too-slow":
|
||||
message = (
|
||||
"Browser не успевает принимать camera stream; "
|
||||
"канал остановлен без накопления задержки."
|
||||
)
|
||||
elif lease.failure_code == "invalid-fmp4":
|
||||
message = "Camera adapter вернул некорректный fMP4 stream."
|
||||
elif lease.failure_code == "segment-too-large":
|
||||
message = "Camera adapter отклонил слишком большой video segment."
|
||||
else:
|
||||
message = _safe_ffmpeg_message(lease.stderr_tail)
|
||||
self._error = {
|
||||
"code": lease.failure_code or "camera-source-ended",
|
||||
"message": message,
|
||||
}
|
||||
def _set_error_locked(self, code: str, message: str) -> None:
|
||||
self._revision += 1
|
||||
self._phase = "error"
|
||||
self._error = {"code": code, "message": message}
|
||||
|
||||
def close(self) -> None:
|
||||
old_process: subprocess.Popen[bytes] | None
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
old_process = self._detach_process_locked()
|
||||
self._revision += 1
|
||||
self._phase = "idle"
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._error = None
|
||||
_terminate_process(old_process)
|
||||
|
||||
def _detach_process_locked(self) -> subprocess.Popen[bytes] | None:
|
||||
process = self._process
|
||||
self._process = None
|
||||
self._process_generation = None
|
||||
return process
|
||||
def _require_open_locked(self) -> None:
|
||||
if self._closed:
|
||||
raise RuntimeError("camera gateway уже закрыт")
|
||||
|
||||
|
||||
def _resolve_ffmpeg(repository_root: Path) -> tuple[Path | None, str]:
|
||||
|
|
@ -394,36 +779,10 @@ def _read_mp4_box(stream: IO[bytes]) -> tuple[bytes, bytes]:
|
|||
return box_type, header + _read_exact(stream, size - len(header))
|
||||
|
||||
|
||||
def _enqueue_segment(lease: CameraProcessLease, kind: str, payload: bytes) -> bool:
|
||||
if len(payload) > MAX_FMP4_SEGMENT_BYTES:
|
||||
lease.failure_code = "segment-too-large"
|
||||
_finish_segment_queue(lease)
|
||||
with suppress(OSError):
|
||||
lease.process.terminate()
|
||||
return False
|
||||
try:
|
||||
lease.segments.put_nowait((kind, payload))
|
||||
return True
|
||||
except queue.Full:
|
||||
lease.failure_code = "consumer-too-slow"
|
||||
while True:
|
||||
try:
|
||||
lease.segments.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
lease.segments.put_nowait(None)
|
||||
with suppress(OSError):
|
||||
lease.process.terminate()
|
||||
return False
|
||||
|
||||
|
||||
def _finish_segment_queue(lease: CameraProcessLease) -> None:
|
||||
def _close_segment_queue(lease: CameraProcessLease) -> None:
|
||||
try:
|
||||
lease.segments.put_nowait(None)
|
||||
except queue.Full:
|
||||
# A full queue is itself a bounded-latency failure. Never leave FFmpeg
|
||||
# back-pressured while the browser accumulates tens of seconds.
|
||||
lease.failure_code = lease.failure_code or "consumer-too-slow"
|
||||
while True:
|
||||
try:
|
||||
lease.segments.get_nowait()
|
||||
|
|
@ -432,11 +791,21 @@ def _finish_segment_queue(lease: CameraProcessLease) -> None:
|
|||
lease.segments.put_nowait(None)
|
||||
|
||||
|
||||
def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
|
||||
stream = lease.process.stdout
|
||||
def _read_fmp4_stdout(
|
||||
gateway: XgridsK1CameraGateway,
|
||||
producer: _CameraProducer,
|
||||
) -> None:
|
||||
stream = producer.process.stdout
|
||||
if stream is None:
|
||||
lease.failure_code = "invalid-fmp4"
|
||||
_finish_segment_queue(lease)
|
||||
try:
|
||||
gateway._mark_producer_failure(
|
||||
producer,
|
||||
"invalid-fmp4",
|
||||
"Camera adapter вернул некорректный fMP4 stream.",
|
||||
)
|
||||
gateway._producer_ended(producer)
|
||||
finally:
|
||||
producer.reader_done.set()
|
||||
return
|
||||
|
||||
init_parts: list[bytes] = []
|
||||
|
|
@ -448,7 +817,11 @@ def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
|
|||
if not init_sent:
|
||||
init_parts.append(box)
|
||||
if box_type == b"moov":
|
||||
if not _enqueue_segment(lease, "init", b"".join(init_parts)):
|
||||
if not gateway._publish_segment(
|
||||
producer,
|
||||
"init",
|
||||
b"".join(init_parts),
|
||||
):
|
||||
return
|
||||
init_sent = True
|
||||
continue
|
||||
|
|
@ -459,7 +832,11 @@ def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
|
|||
if fragment_parts:
|
||||
fragment_parts.append(box)
|
||||
if box_type == b"mdat":
|
||||
if not _enqueue_segment(lease, "media", b"".join(fragment_parts)):
|
||||
if not gateway._publish_segment(
|
||||
producer,
|
||||
"media",
|
||||
b"".join(fragment_parts),
|
||||
):
|
||||
return
|
||||
fragment_parts = []
|
||||
continue
|
||||
|
|
@ -469,15 +846,32 @@ def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
|
|||
fragment_parts.append(box)
|
||||
except EOFError:
|
||||
if not init_sent:
|
||||
lease.failure_code = "invalid-fmp4"
|
||||
gateway._mark_producer_failure(
|
||||
producer,
|
||||
"invalid-fmp4",
|
||||
"Camera adapter вернул некорректный fMP4 stream.",
|
||||
)
|
||||
elif fragment_parts:
|
||||
gateway._mark_producer_failure(
|
||||
producer,
|
||||
"incomplete-fmp4-fragment",
|
||||
"Camera stream завершился на неполном video fragment.",
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
lease.failure_code = "invalid-fmp4"
|
||||
gateway._mark_producer_failure(
|
||||
producer,
|
||||
"invalid-fmp4",
|
||||
"Camera adapter вернул некорректный fMP4 stream.",
|
||||
)
|
||||
finally:
|
||||
_finish_segment_queue(lease)
|
||||
try:
|
||||
gateway._producer_ended(producer)
|
||||
finally:
|
||||
producer.reader_done.set()
|
||||
|
||||
|
||||
def _drain_stderr(lease: CameraProcessLease) -> None:
|
||||
stream = lease.process.stderr
|
||||
def _drain_stderr(producer: _CameraProducer) -> None:
|
||||
stream = producer.process.stderr
|
||||
if stream is None:
|
||||
return
|
||||
try:
|
||||
|
|
@ -487,7 +881,7 @@ def _drain_stderr(lease: CameraProcessLease) -> None:
|
|||
return
|
||||
text = line.decode("utf-8", errors="replace").strip()
|
||||
if text:
|
||||
lease.stderr_tail.append(text[-240:])
|
||||
producer.stderr_tail.append(text[-240:])
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
|
||||
|
|
@ -504,13 +898,21 @@ def _safe_ffmpeg_message(stderr_tail: deque[str]) -> str:
|
|||
return "Camera stream завершился до первого пригодного видеофрагмента."
|
||||
|
||||
|
||||
def _terminate_process(process: subprocess.Popen[bytes] | None) -> None:
|
||||
def _terminate_process(
|
||||
process: subprocess.Popen[bytes] | None,
|
||||
*,
|
||||
close_streams: bool = True,
|
||||
) -> None:
|
||||
if process is None:
|
||||
return
|
||||
try:
|
||||
if process.poll() is None:
|
||||
if os.name == "posix":
|
||||
with suppress(ProcessLookupError):
|
||||
# The process can have exited or lost its dedicated process
|
||||
# group between poll() and killpg(). Either outcome means
|
||||
# there is no group left for us to signal; still reap the
|
||||
# child and finish the archive instead of aborting teardown.
|
||||
with suppress(OSError):
|
||||
os.killpg(process.pid, signal.SIGINT)
|
||||
else:
|
||||
process.terminate()
|
||||
|
|
@ -524,10 +926,15 @@ def _terminate_process(process: subprocess.Popen[bytes] | None) -> None:
|
|||
process.kill()
|
||||
process.wait(timeout=1.0)
|
||||
finally:
|
||||
for stream in (process.stdout, process.stderr):
|
||||
if stream is not None:
|
||||
with suppress(OSError):
|
||||
stream.close()
|
||||
if close_streams:
|
||||
_close_process_streams(process)
|
||||
|
||||
|
||||
def _close_process_streams(process: subprocess.Popen[bytes]) -> None:
|
||||
for stream in (process.stdout, process.stderr):
|
||||
if stream is not None:
|
||||
with suppress(OSError):
|
||||
stream.close()
|
||||
|
||||
|
||||
def build_xgrids_k1_camera_router(
|
||||
|
|
@ -536,9 +943,7 @@ def build_xgrids_k1_camera_router(
|
|||
) -> APIRouter:
|
||||
router = APIRouter(include_in_schema=False)
|
||||
|
||||
@router.websocket(
|
||||
f"/api/v1/device-plugins/{plugin_id}/camera-preview/{{generation}}"
|
||||
)
|
||||
@router.websocket(f"/api/v1/device-plugins/{plugin_id}/camera-preview/{{generation}}")
|
||||
async def camera_preview(websocket: WebSocket, generation: int) -> None:
|
||||
await websocket.accept()
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import importlib.util
|
|||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Protocol, cast
|
||||
|
|
@ -20,6 +22,7 @@ from k1link.ble.scanner import scan
|
|||
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
||||
from k1link.mqtt import validate_private_ipv4
|
||||
from k1link.protocol.normalizer import normalize_k1_message
|
||||
from k1link.sessions import ActiveSessionLease, resolve_missioncore_evidence_dir
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
||||
from k1link.web.device_lifecycle import (
|
||||
|
|
@ -181,6 +184,7 @@ class XgridsK1CompatibilityService:
|
|||
|
||||
def __init__(self, repository_root: Path) -> None:
|
||||
self.repository_root = repository_root.resolve()
|
||||
self.evidence_root = resolve_missioncore_evidence_dir(self.repository_root)
|
||||
self._lock = threading.Lock()
|
||||
self._provisioning_gate = threading.Lock()
|
||||
self._provisioning_active = False
|
||||
|
|
@ -206,6 +210,7 @@ class XgridsK1CompatibilityService:
|
|||
self._acquisition_out_dir: Path | None = None
|
||||
self._acquisition_start_operation_id: str | None = None
|
||||
self._acquisition_stop_operation_id: str | None = None
|
||||
self._acquisition_session_lease: ActiveSessionLease | None = None
|
||||
# The host-owned visual runtime receives the vendor normalizer
|
||||
# explicitly. There is no implicit K1 decoder in the visual layer.
|
||||
self.runtime = VisualizationRuntime(normalizer=normalize_k1_message)
|
||||
|
|
@ -216,7 +221,8 @@ class XgridsK1CompatibilityService:
|
|||
|
||||
def state(self) -> dict[str, Any]:
|
||||
runtime = self.runtime.snapshot()
|
||||
self._reconcile_acquisition(runtime)
|
||||
camera_preview = self.camera_preview.snapshot()
|
||||
self._reconcile_acquisition(runtime, camera_preview)
|
||||
camera_preview = self.camera_preview.snapshot()
|
||||
metrics = runtime["metrics"]
|
||||
with self._lock:
|
||||
|
|
@ -401,9 +407,6 @@ class XgridsK1CompatibilityService:
|
|||
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||
if request.device_id not in known_ids:
|
||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||
# A reprovision can replace both the device session and its address.
|
||||
# Revoke the previous browser lease before any new device-side write.
|
||||
self.camera_preview.stop_current()
|
||||
# Unwrap once at the provisioning service boundary. The plain value is
|
||||
# kept only in this stack frame, included in a keyed request digest, and
|
||||
# passed to the reviewed BLE write boundary; it is never journaled.
|
||||
|
|
@ -463,12 +466,17 @@ class XgridsK1CompatibilityService:
|
|||
)
|
||||
self._provisioning_active = True
|
||||
|
||||
# A reprovision can replace both the device session and its address.
|
||||
# Revoke preview/producer state only after the active-acquisition
|
||||
# guard; a rejected network write must never stop evidence capture.
|
||||
self.camera_preview.stop_current()
|
||||
|
||||
self._set_operation(
|
||||
"provisioning",
|
||||
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
|
||||
)
|
||||
session_dir = _new_operation_session_dir(
|
||||
self.repository_root,
|
||||
self.evidence_root,
|
||||
"viewer_wifi_provisioning",
|
||||
)
|
||||
session_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
|
@ -654,7 +662,7 @@ class XgridsK1CompatibilityService:
|
|||
},
|
||||
)
|
||||
self._acquisition = acquisition
|
||||
self._acquisition_out_dir = new_live_session_dir(self.repository_root)
|
||||
self._acquisition_out_dir = new_live_session_dir(self.evidence_root)
|
||||
self._acquisition_start_operation_id = None
|
||||
self._acquisition_stop_operation_id = None
|
||||
self._compatibility_attestation = _attestation_snapshot(
|
||||
|
|
@ -725,12 +733,27 @@ class XgridsK1CompatibilityService:
|
|||
message_code="acquisition.start.waiting_receiver_ready",
|
||||
)
|
||||
self._acquisition_start_operation_id = operation.operation_id
|
||||
lease = ActiveSessionLease.acquire(out_dir.parent, out_dir)
|
||||
with self._lock:
|
||||
if self._acquisition_session_lease is not None:
|
||||
lease.release()
|
||||
raise RuntimeError("evidence-сессия уже удерживается активным acquisition")
|
||||
self._acquisition_session_lease = lease
|
||||
self.runtime.start_live(
|
||||
acquisition.target_host,
|
||||
out_dir,
|
||||
duration_seconds=acquisition.duration_seconds,
|
||||
)
|
||||
self._arm_camera_recording(out_dir)
|
||||
except Exception as exc:
|
||||
with suppress(Exception):
|
||||
self.camera_preview.stop_recording(
|
||||
status="failed",
|
||||
failure_code="acquisition-start-failed",
|
||||
)
|
||||
with suppress(Exception):
|
||||
self.runtime.stop()
|
||||
self._release_acquisition_session_lease()
|
||||
with self._lock:
|
||||
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
acquisition.transition("failed", message_code="acquisition.start.failed")
|
||||
|
|
@ -845,8 +868,10 @@ class XgridsK1CompatibilityService:
|
|||
try:
|
||||
with self._lock:
|
||||
acquisition.transition("stopping", message_code="acquisition.stopping")
|
||||
self.camera_preview.stop_current()
|
||||
self.runtime.stop()
|
||||
self._stop_acquisition_sources(
|
||||
camera_status="complete",
|
||||
camera_failure_code=None,
|
||||
)
|
||||
self._cancel_pending_acquisition_operations(
|
||||
exclude_operation_id=operation.operation_id,
|
||||
reason_code="superseded-by-stop",
|
||||
|
|
@ -915,8 +940,10 @@ class XgridsK1CompatibilityService:
|
|||
return self.state()
|
||||
try:
|
||||
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
self.camera_preview.stop_current()
|
||||
self.runtime.stop()
|
||||
self._stop_acquisition_sources(
|
||||
camera_status="interrupted",
|
||||
camera_failure_code="acquisition-aborted",
|
||||
)
|
||||
self._cancel_pending_acquisition_operations(
|
||||
exclude_operation_id=operation.operation_id,
|
||||
reason_code="superseded-by-abort",
|
||||
|
|
@ -997,10 +1024,10 @@ class XgridsK1CompatibilityService:
|
|||
def stop(self) -> dict[str, Any]:
|
||||
"""Deprecated capture-only shim; it never claims that K1 stopped scanning."""
|
||||
|
||||
self.camera_preview.stop_current()
|
||||
with self._lock:
|
||||
acquisition = self._acquisition
|
||||
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
||||
self.camera_preview.stop_current()
|
||||
self.runtime.stop()
|
||||
return self.state()
|
||||
return self.stop_acquisition(
|
||||
|
|
@ -1012,6 +1039,16 @@ class XgridsK1CompatibilityService:
|
|||
|
||||
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]:
|
||||
target = self._camera_target_for_session(request.device_session_id)
|
||||
with self._lock:
|
||||
acquisition = self._acquisition
|
||||
out_dir = self._acquisition_out_dir
|
||||
acquisition_active = (
|
||||
acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES
|
||||
)
|
||||
if acquisition_active:
|
||||
if out_dir is None:
|
||||
raise RuntimeError("для acquisition не выделена evidence-сессия")
|
||||
self._arm_camera_recording(out_dir, require_session=True)
|
||||
self.camera_preview.select(request.source_id, target)
|
||||
return self.state()
|
||||
|
||||
|
|
@ -1035,6 +1072,8 @@ class XgridsK1CompatibilityService:
|
|||
f"{type(camera_error).__name__}: {camera_error}"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self._release_acquisition_session_lease()
|
||||
if camera_error is not None:
|
||||
raise camera_error
|
||||
|
||||
|
|
@ -1053,6 +1092,71 @@ class XgridsK1CompatibilityService:
|
|||
)
|
||||
return self.state()
|
||||
|
||||
def _arm_camera_recording(
|
||||
self,
|
||||
out_dir: Path,
|
||||
*,
|
||||
require_session: bool = False,
|
||||
) -> None:
|
||||
camera = self.camera_preview.snapshot()
|
||||
if camera["recording"]["active"]:
|
||||
self.camera_preview.start_recording(out_dir)
|
||||
return
|
||||
if not out_dir.is_dir():
|
||||
if not require_session and camera["active_source_id"] is None:
|
||||
# The real runtime creates the session in its source thread. If
|
||||
# no camera has been selected yet, defer arming until selection
|
||||
# instead of racing that mkdir. No media can be lost yet.
|
||||
return
|
||||
deadline = time.monotonic() + 5.0
|
||||
while not out_dir.is_dir():
|
||||
runtime = self.runtime.snapshot()
|
||||
if runtime.get("phase") == "error":
|
||||
raise RuntimeError(
|
||||
"evidence-сессия не создана: live runtime завершился ошибкой"
|
||||
)
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError(
|
||||
"evidence-сессия не появилась вовремя для записи camera stream"
|
||||
)
|
||||
time.sleep(0.01)
|
||||
self.camera_preview.start_recording(out_dir)
|
||||
|
||||
def _stop_acquisition_sources(
|
||||
self,
|
||||
*,
|
||||
camera_status: Literal["complete", "interrupted", "failed"],
|
||||
camera_failure_code: str | None,
|
||||
) -> None:
|
||||
camera_error: Exception | None = None
|
||||
try:
|
||||
self.camera_preview.stop_recording(
|
||||
status=camera_status,
|
||||
failure_code=camera_failure_code,
|
||||
)
|
||||
except Exception as exc:
|
||||
camera_error = exc
|
||||
try:
|
||||
self.runtime.stop()
|
||||
except Exception as exc:
|
||||
if camera_error is not None:
|
||||
exc.add_note(
|
||||
"camera archive cleanup also failed: "
|
||||
f"{type(camera_error).__name__}: {camera_error}"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self._release_acquisition_session_lease()
|
||||
if camera_error is not None:
|
||||
raise camera_error
|
||||
|
||||
def _release_acquisition_session_lease(self) -> None:
|
||||
with self._lock:
|
||||
lease = self._acquisition_session_lease
|
||||
self._acquisition_session_lease = None
|
||||
if lease is not None:
|
||||
lease.release()
|
||||
|
||||
def _set_operation(self, phase: str, message: str) -> None:
|
||||
with self._lock:
|
||||
self._operation_phase = phase
|
||||
|
|
@ -1127,13 +1231,20 @@ class XgridsK1CompatibilityService:
|
|||
raise ValueError("указана неизвестная acquisition-сессия")
|
||||
return acquisition
|
||||
|
||||
def _reconcile_acquisition(self, runtime: Mapping[str, Any]) -> None:
|
||||
def _reconcile_acquisition(
|
||||
self,
|
||||
runtime: Mapping[str, Any],
|
||||
camera: Mapping[str, Any],
|
||||
) -> None:
|
||||
completed_operation_id: str | None = None
|
||||
failed_operation_id: str | None = None
|
||||
failed_stop_operation_id: str | None = None
|
||||
unconfirmed_stop_operation_id: str | None = None
|
||||
receiver_ready_operation_id: str | None = None
|
||||
acquisition_id: str | None = None
|
||||
camera_terminal_status: Literal["complete", "failed"] | None = None
|
||||
camera_failure_code: str | None = None
|
||||
stop_runtime_for_camera_failure = False
|
||||
with self._lock:
|
||||
acquisition = self._acquisition
|
||||
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
||||
|
|
@ -1143,7 +1254,42 @@ class XgridsK1CompatibilityService:
|
|||
phase = runtime.get("phase")
|
||||
source_mode = runtime.get("source_mode")
|
||||
source_ready = runtime.get("source_ready") is True
|
||||
if acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
|
||||
camera_recording = camera.get("recording")
|
||||
camera_recording_active = (
|
||||
isinstance(camera_recording, dict) and camera_recording.get("active") is True
|
||||
)
|
||||
camera_error = camera.get("error")
|
||||
if camera.get("phase") == "error" and camera_recording_active:
|
||||
waiting_for_start = acquisition.state in {
|
||||
"starting",
|
||||
"awaiting_external_start",
|
||||
}
|
||||
waiting_for_stop = acquisition.state in {
|
||||
"awaiting_external_stop",
|
||||
"stopping",
|
||||
"finalizing",
|
||||
}
|
||||
camera_failure_code = (
|
||||
str(camera_error.get("code"))
|
||||
if isinstance(camera_error, dict) and camera_error.get("code")
|
||||
else "camera-producer-failed"
|
||||
)
|
||||
acquisition.transition(
|
||||
"failed",
|
||||
message_code="acquisition.camera_failed",
|
||||
result={
|
||||
"receiver_stopped": False,
|
||||
"device_state": "unknown",
|
||||
"camera_failure_code": camera_failure_code,
|
||||
},
|
||||
)
|
||||
if waiting_for_start:
|
||||
failed_operation_id = self._acquisition_start_operation_id
|
||||
if waiting_for_stop:
|
||||
failed_stop_operation_id = self._acquisition_stop_operation_id
|
||||
camera_terminal_status = "failed"
|
||||
stop_runtime_for_camera_failure = True
|
||||
elif acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
|
||||
acquisition.transition("acquiring", message_code="acquisition.acquiring")
|
||||
completed_operation_id = self._acquisition_start_operation_id
|
||||
acquisition_id = acquisition.acquisition_id
|
||||
|
|
@ -1168,6 +1314,8 @@ class XgridsK1CompatibilityService:
|
|||
"finalizing",
|
||||
}
|
||||
acquisition.transition("failed", message_code="acquisition.runtime_failed")
|
||||
camera_terminal_status = "failed"
|
||||
camera_failure_code = "runtime-failed"
|
||||
if waiting_for_start:
|
||||
failed_operation_id = self._acquisition_start_operation_id
|
||||
if waiting_for_stop:
|
||||
|
|
@ -1181,6 +1329,8 @@ class XgridsK1CompatibilityService:
|
|||
message_code="acquisition.receiver_completed_without_point_data",
|
||||
result={"receiver_stopped": True, "device_state": "unknown"},
|
||||
)
|
||||
camera_terminal_status = "failed"
|
||||
camera_failure_code = "receiver-completed-without-point-data"
|
||||
failed_operation_id = self._acquisition_start_operation_id
|
||||
elif acquisition.state == "acquiring" and source_mode == "idle":
|
||||
acquisition.transition(
|
||||
|
|
@ -1188,14 +1338,28 @@ class XgridsK1CompatibilityService:
|
|||
message_code="acquisition.receiver_completed",
|
||||
result={"receiver_stopped": True, "device_state": "unknown"},
|
||||
)
|
||||
camera_terminal_status = "complete"
|
||||
elif acquisition.state == "awaiting_external_stop" and source_mode == "idle":
|
||||
acquisition.transition(
|
||||
"failed",
|
||||
message_code="acquisition.receiver_completed_before_stop_confirmation",
|
||||
result={"receiver_stopped": True, "device_state": "unknown"},
|
||||
)
|
||||
camera_terminal_status = "failed"
|
||||
camera_failure_code = "receiver-completed-before-stop-confirmation"
|
||||
unconfirmed_stop_operation_id = self._acquisition_stop_operation_id
|
||||
|
||||
if camera_terminal_status is not None:
|
||||
try:
|
||||
self.camera_preview.stop_recording(
|
||||
status=camera_terminal_status,
|
||||
failure_code=camera_failure_code,
|
||||
)
|
||||
finally:
|
||||
self._release_acquisition_session_lease()
|
||||
if stop_runtime_for_camera_failure:
|
||||
self.runtime.stop()
|
||||
|
||||
if receiver_ready_operation_id is not None:
|
||||
self._operations.transition_if_pending(
|
||||
receiver_ready_operation_id,
|
||||
|
|
@ -1581,9 +1745,9 @@ def _device_calibration_snapshot(active_profile_id: str | None) -> dict[str, Any
|
|||
}
|
||||
|
||||
|
||||
def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path:
|
||||
def _new_operation_session_dir(sessions_root: Path, suffix: str) -> Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
base = repository_root / "sessions" / f"{stamp}_{suffix}"
|
||||
base = sessions_root / f"{stamp}_{suffix}"
|
||||
candidate = base
|
||||
serial = 1
|
||||
while candidate.exists():
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.sessions import ActiveSessionLease, recover_stale_active_session_marker
|
||||
from k1link.sessions.legacy import discover_legacy_viewer_sessions
|
||||
|
||||
|
||||
def test_active_session_lease_hides_live_evidence_until_release(tmp_path: Path) -> None:
|
||||
sessions = tmp_path / "sessions"
|
||||
session = sessions / "20260717T011050Z_viewer_live"
|
||||
|
||||
lease = ActiveSessionLease.acquire(sessions, session)
|
||||
try:
|
||||
session.mkdir()
|
||||
capture = session / "captures" / "mqtt_live"
|
||||
capture.mkdir(parents=True)
|
||||
(capture / "mqtt.raw.k1mqtt").write_bytes(b"K1MQTT\x00unfinished")
|
||||
candidates = discover_legacy_viewer_sessions(sessions)
|
||||
assert candidates == ()
|
||||
assert recover_stale_active_session_marker(sessions) is False
|
||||
finally:
|
||||
lease.release()
|
||||
|
||||
assert not (sessions / ".current_session").exists()
|
||||
candidates = discover_legacy_viewer_sessions(sessions)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].session_id == session.name
|
||||
assert candidates[0].replayable is False
|
||||
|
||||
|
||||
def test_startup_recovery_removes_only_an_unlocked_stale_marker(tmp_path: Path) -> None:
|
||||
sessions = tmp_path / "sessions"
|
||||
sessions.mkdir()
|
||||
marker = sessions / ".current_session"
|
||||
marker.write_text("20260717T011050Z_viewer_live\n", encoding="utf-8")
|
||||
|
||||
assert recover_stale_active_session_marker(sessions) is True
|
||||
assert not marker.exists()
|
||||
assert recover_stale_active_session_marker(sessions) is False
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.sessions.legacy import discover_legacy_viewer_sessions
|
||||
from k1link.web.camera_archive import (
|
||||
CAMERA_ARCHIVE_SCHEMA,
|
||||
CAMERA_COMMIT_POLICY,
|
||||
CameraArchiveError,
|
||||
CameraArchiveWriter,
|
||||
recover_incomplete_camera_archives,
|
||||
)
|
||||
|
||||
|
||||
def test_camera_archive_writes_canonical_segments_and_seals_summary(tmp_path: Path) -> None:
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
writer = CameraArchiveWriter(
|
||||
session,
|
||||
"sensor.camera.left",
|
||||
7,
|
||||
commit_interval_seconds=60,
|
||||
commit_bytes=1024,
|
||||
)
|
||||
|
||||
init = b"init-segment"
|
||||
first = b"first-fragment"
|
||||
second = b"second-fragment"
|
||||
writer.append("init", init, host_epoch_ns=100, host_monotonic_ns=200)
|
||||
writer.append("media", first, host_epoch_ns=110, host_monotonic_ns=210)
|
||||
writer.append("media", second, host_epoch_ns=120, host_monotonic_ns=220)
|
||||
|
||||
# The tuning values cannot weaken the segment RPO: every append has already
|
||||
# published its fragment, index record, and interrupted crash checkpoint.
|
||||
checkpoint = json.loads(writer.summary_path.read_text(encoding="utf-8"))
|
||||
assert checkpoint["status"] == "interrupted"
|
||||
assert checkpoint["segment_count"] == 2
|
||||
assert checkpoint["commit_policy"] == CAMERA_COMMIT_POLICY
|
||||
|
||||
summary = writer.close()
|
||||
entries = [json.loads(line) for line in writer.index_path.read_text().splitlines()]
|
||||
assert writer.archive_dir.name == "epoch-7"
|
||||
assert writer.init_path.read_bytes() == init
|
||||
assert [path.name for path in sorted(writer.segments_dir.iterdir())] == [
|
||||
"1.m4s",
|
||||
"2.m4s",
|
||||
]
|
||||
assert [entry["kind"] for entry in entries] == ["media", "media"]
|
||||
assert [entry["sequence"] for entry in entries] == [1, 2]
|
||||
assert [entry["path"] for entry in entries] == [
|
||||
"segments/1.m4s",
|
||||
"segments/2.m4s",
|
||||
]
|
||||
for entry, expected in zip(entries, (first, second), strict=True):
|
||||
payload = (writer.archive_dir / entry["path"]).read_bytes()
|
||||
assert payload == expected
|
||||
assert entry["length"] == len(expected)
|
||||
assert entry["sha256"] == hashlib.sha256(expected).hexdigest()
|
||||
assert summary["schema_version"] == CAMERA_ARCHIVE_SCHEMA
|
||||
assert summary["status"] == "complete"
|
||||
assert summary["segment_count"] == 2
|
||||
assert summary["entry_count"] == 2
|
||||
assert summary["media_segment_count"] == 2
|
||||
assert summary["valid_bytes"] == len(init) + len(first) + len(second)
|
||||
assert summary["stream_sha256"] == hashlib.sha256(init + first + second).hexdigest()
|
||||
assert summary["synchronization"] == "host-arrival-best-effort"
|
||||
assert summary["artifacts"] == {
|
||||
"init": "init.mp4",
|
||||
"segments": "segments",
|
||||
"index": "index.jsonl",
|
||||
}
|
||||
assert "192.168" not in json.dumps(summary)
|
||||
assert writer.close() == summary
|
||||
|
||||
|
||||
def test_camera_archive_rejects_media_without_init_and_existing_epoch(tmp_path: Path) -> None:
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
writer = CameraArchiveWriter(session, "sensor.camera.right", 1)
|
||||
with pytest.raises(CameraArchiveError, match="before"):
|
||||
writer.append("media", b"fragment")
|
||||
writer.append("init", b"init")
|
||||
writer.close(status="interrupted", failure_code="source-ended")
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
CameraArchiveWriter(session, "sensor.camera.right", 1)
|
||||
with pytest.raises(ValueError, match="safe storage"):
|
||||
CameraArchiveWriter(session, "../camera", 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("symlink_component", ["media", "source"])
|
||||
def test_camera_writer_never_follows_precreated_storage_symlinks(
|
||||
tmp_path: Path,
|
||||
symlink_component: str,
|
||||
) -> None:
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
if symlink_component == "media":
|
||||
(session / "media").symlink_to(outside, target_is_directory=True)
|
||||
else:
|
||||
media = session / "media"
|
||||
media.mkdir()
|
||||
(media / "sensor.camera.left").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(CameraArchiveError, match="no-follow"):
|
||||
CameraArchiveWriter(session, "sensor.camera.left", 1)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
|
||||
def test_camera_writer_keeps_directory_fds_across_path_swap(tmp_path: Path) -> None:
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
|
||||
writer.append("init", b"init")
|
||||
|
||||
original_epoch = writer.archive_dir
|
||||
moved_epoch = original_epoch.with_name("epoch-1-moved")
|
||||
original_epoch.rename(moved_epoch)
|
||||
outside_epoch = tmp_path / "outside-epoch"
|
||||
outside_epoch.mkdir()
|
||||
original_epoch.symlink_to(outside_epoch, target_is_directory=True)
|
||||
|
||||
original_segments = moved_epoch / "segments"
|
||||
moved_segments = moved_epoch / "segments-held-by-fd"
|
||||
original_segments.rename(moved_segments)
|
||||
outside_segments = tmp_path / "outside-segments"
|
||||
outside_segments.mkdir()
|
||||
original_segments.symlink_to(outside_segments, target_is_directory=True)
|
||||
|
||||
writer.append("media", b"frame-after-path-swap")
|
||||
summary = writer.close()
|
||||
|
||||
assert (moved_segments / "1.m4s").read_bytes() == b"frame-after-path-swap"
|
||||
assert json.loads((moved_epoch / "summary.json").read_text(encoding="utf-8")) == summary
|
||||
assert list(outside_epoch.iterdir()) == []
|
||||
assert list(outside_segments.iterdir()) == []
|
||||
|
||||
|
||||
def test_recovery_seals_unindexed_durable_tail_and_preserves_orphans(tmp_path: Path) -> None:
|
||||
sessions_root = tmp_path / "sessions"
|
||||
session = sessions_root / "20260717T011050Z_viewer_live"
|
||||
session.mkdir(parents=True)
|
||||
writer = CameraArchiveWriter(session, "sensor.camera.left", 3)
|
||||
writer.append("init", b"init")
|
||||
writer.append("media", b"frame-1", host_epoch_ns=100, host_monotonic_ns=200)
|
||||
|
||||
# Catalog refresh must not hash or rewrite a writer that is still active in
|
||||
# this server process.
|
||||
assert recover_incomplete_camera_archives(sessions_root) == ()
|
||||
writer.close(status="interrupted", failure_code="synthetic-process-crash")
|
||||
|
||||
epoch = writer.archive_dir
|
||||
writer.summary_path.unlink()
|
||||
# Simulate a process dying after atomically publishing the next fragment but
|
||||
# before its JSONL entry, plus a non-contiguous fragment that cannot be put on
|
||||
# the trusted timeline. Recovery salvages #2 and quarantines (never deletes) #4.
|
||||
(writer.segments_dir / "2.m4s").write_bytes(b"frame-2")
|
||||
(writer.segments_dir / "4.m4s").write_bytes(b"orphan-frame")
|
||||
|
||||
recovered = recover_incomplete_camera_archives(sessions_root)
|
||||
|
||||
assert len(recovered) == 1
|
||||
summary = recovered[0]
|
||||
assert summary["status"] == "interrupted"
|
||||
assert summary["failure_code"] == "server-process-interrupted"
|
||||
assert summary["segment_count"] == 2
|
||||
entries = [
|
||||
json.loads(line)
|
||||
for line in writer.index_path.read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
assert [entry["sequence"] for entry in entries] == [1, 2]
|
||||
assert entries[0]["host_epoch_ns"] == 100
|
||||
assert entries[1]["recovered"] is True
|
||||
assert (writer.segments_dir / "2.m4s").read_bytes() == b"frame-2"
|
||||
assert not (writer.segments_dir / "4.m4s").exists()
|
||||
assert any(
|
||||
path.read_bytes() == b"orphan-frame"
|
||||
for path in (epoch / "recovery-orphans").iterdir()
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
# Recovery output is exactly the layout consumed by legacy media discovery,
|
||||
# and a second scan is an idempotent no-op.
|
||||
assert recover_incomplete_camera_archives(sessions_root) == ()
|
||||
candidates = discover_legacy_viewer_sessions(sessions_root)
|
||||
assert len(candidates) == 1
|
||||
assert [source.source_id for source in candidates[0].media_sources] == [
|
||||
"sensor.camera.left"
|
||||
]
|
||||
|
||||
|
||||
def test_recovery_replaces_index_and_summary_symlinks_without_following_targets(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sessions_root = tmp_path / "sessions"
|
||||
session = sessions_root / "20260717T011051Z_viewer_live"
|
||||
session.mkdir(parents=True)
|
||||
writer = CameraArchiveWriter(session, "sensor.camera.right", 1)
|
||||
writer.append("init", b"init")
|
||||
writer.append("media", b"frame")
|
||||
writer.close(status="interrupted")
|
||||
|
||||
outside_index = tmp_path / "outside-index"
|
||||
outside_summary = tmp_path / "outside-summary"
|
||||
outside_index.write_bytes(b"do-not-read-or-overwrite-index")
|
||||
outside_summary.write_bytes(b"do-not-read-or-overwrite-summary")
|
||||
writer.index_path.unlink()
|
||||
writer.summary_path.unlink()
|
||||
writer.index_path.symlink_to(outside_index)
|
||||
writer.summary_path.symlink_to(outside_summary)
|
||||
|
||||
recovered = recover_incomplete_camera_archives(sessions_root)
|
||||
|
||||
assert len(recovered) == 1
|
||||
assert recovered[0]["segment_count"] == 1
|
||||
assert outside_index.read_bytes() == b"do-not-read-or-overwrite-index"
|
||||
assert outside_summary.read_bytes() == b"do-not-read-or-overwrite-summary"
|
||||
assert writer.index_path.is_symlink() is False
|
||||
assert writer.summary_path.is_symlink() is False
|
||||
assert json.loads(writer.index_path.read_text(encoding="utf-8"))["recovered"] is True
|
||||
|
||||
|
||||
def test_recovery_fails_closed_on_symlinked_quarantine_directory(tmp_path: Path) -> None:
|
||||
sessions_root = tmp_path / "sessions"
|
||||
session = sessions_root / "20260717T011052Z_viewer_live"
|
||||
session.mkdir(parents=True)
|
||||
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
|
||||
writer.append("init", b"init")
|
||||
writer.append("media", b"frame-1")
|
||||
writer.close(status="interrupted")
|
||||
writer.summary_path.unlink()
|
||||
orphan = writer.segments_dir / "3.m4s"
|
||||
orphan.write_bytes(b"orphan")
|
||||
outside = tmp_path / "outside-quarantine"
|
||||
outside.mkdir()
|
||||
(writer.archive_dir / "recovery-orphans").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(CameraArchiveError, match="real directory"):
|
||||
recover_incomplete_camera_archives(sessions_root)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
assert orphan.read_bytes() == b"orphan"
|
||||
|
||||
|
||||
def test_recovery_quarantines_segment_symlink_without_reading_target(tmp_path: Path) -> None:
|
||||
sessions_root = tmp_path / "sessions"
|
||||
session = sessions_root / "20260717T011053Z_viewer_live"
|
||||
session.mkdir(parents=True)
|
||||
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
|
||||
writer.append("init", b"init")
|
||||
writer.append("media", b"frame-1")
|
||||
writer.close(status="interrupted")
|
||||
writer.summary_path.unlink()
|
||||
outside = tmp_path / "outside-segment"
|
||||
outside.write_bytes(b"external-evidence-must-not-be-read-or-moved")
|
||||
(writer.segments_dir / "2.m4s").symlink_to(outside)
|
||||
|
||||
recovered = recover_incomplete_camera_archives(sessions_root)
|
||||
|
||||
assert len(recovered) == 1
|
||||
assert recovered[0]["segment_count"] == 1
|
||||
assert outside.read_bytes() == b"external-evidence-must-not-be-read-or-moved"
|
||||
quarantined = list((writer.archive_dir / "recovery-orphans").glob("2.m4s*"))
|
||||
assert len(quarantined) == 1
|
||||
assert quarantined[0].is_symlink()
|
||||
assert quarantined[0].resolve() == outside.resolve()
|
||||
|
||||
|
||||
def test_recovery_lock_symlink_is_rejected_without_touching_target(tmp_path: Path) -> None:
|
||||
sessions_root = tmp_path / "sessions"
|
||||
sessions_root.mkdir()
|
||||
outside = tmp_path / "outside-lock"
|
||||
outside.write_bytes(b"external-lock-target")
|
||||
(sessions_root / ".camera-recovery.lock").symlink_to(outside)
|
||||
|
||||
with pytest.raises(CameraArchiveError, match="lock failed no-follow"):
|
||||
recover_incomplete_camera_archives(sessions_root)
|
||||
|
||||
assert outside.read_bytes() == b"external-lock-target"
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="uses POSIX flock to assert worker serialization")
|
||||
def test_recovery_startup_lease_serializes_another_process(tmp_path: Path) -> None:
|
||||
import fcntl
|
||||
|
||||
sessions_root = tmp_path / "sessions"
|
||||
sessions_root.mkdir()
|
||||
lock_path = sessions_root / ".camera-recovery.lock"
|
||||
lock_path.touch(mode=0o600)
|
||||
descriptor = os.open(lock_path, os.O_RDWR)
|
||||
process: subprocess.Popen[str] | None = None
|
||||
locked = False
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
locked = True
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"from pathlib import Path; "
|
||||
"from k1link.web.camera_archive import "
|
||||
"recover_incomplete_camera_archives; "
|
||||
"recover_incomplete_camera_archives(Path(__import__('sys').argv[1])); "
|
||||
"print('recovered')"
|
||||
),
|
||||
str(sessions_root),
|
||||
],
|
||||
cwd=Path(__file__).parents[1],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
time.sleep(0.15)
|
||||
assert process.poll() is None
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
locked = False
|
||||
stdout, stderr = process.communicate(timeout=5)
|
||||
assert process.returncode == 0, stderr
|
||||
assert stdout.strip() == "recovered"
|
||||
finally:
|
||||
if locked:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
if process is not None and process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
|
@ -10,6 +10,7 @@ import pytest
|
|||
from paho.mqtt.packettypes import PacketTypes
|
||||
from paho.mqtt.reasoncodes import ReasonCode
|
||||
|
||||
import k1link.mqtt.capture as capture_module
|
||||
from k1link.mqtt.capture import (
|
||||
FRAME_HEADER,
|
||||
RAW_MAGIC,
|
||||
|
|
@ -256,3 +257,85 @@ def test_capture_reader_rejects_invalid_or_unbounded_frames(
|
|||
|
||||
with pytest.raises(CaptureFormatError, match=message):
|
||||
list(iter_capture_frames(path, max_payload_bytes=4))
|
||||
|
||||
|
||||
def _mqtt_message(topic: str, payload: bytes) -> mqtt.MQTTMessage:
|
||||
message = mqtt.MQTTMessage(topic=topic.encode("utf-8"))
|
||||
message.payload = payload
|
||||
message.qos = 0
|
||||
message.retain = False
|
||||
message.dup = False
|
||||
return message
|
||||
|
||||
|
||||
def test_group_commit_fsyncs_raw_before_publishing_metadata(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
|
||||
writer.open()
|
||||
assert writer._raw is not None # noqa: SLF001
|
||||
assert writer._metadata is not None # noqa: SLF001
|
||||
raw_fd = writer._raw.fileno() # noqa: SLF001
|
||||
metadata_fd = writer._metadata.fileno() # noqa: SLF001
|
||||
fsync_calls: list[int] = []
|
||||
real_fsync = capture_module.os.fsync
|
||||
|
||||
def observe_fsync(descriptor: int) -> None:
|
||||
fsync_calls.append(descriptor)
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(capture_module.os, "fsync", observe_fsync)
|
||||
monkeypatch.setattr(capture_module, "GROUP_COMMIT_MAX_MESSAGES", 2)
|
||||
|
||||
writer.record(_mqtt_message("RealtimePath", b"one"))
|
||||
assert writer.metadata_path.read_bytes() == b""
|
||||
writer.record(_mqtt_message("RealtimePath", b"two"))
|
||||
|
||||
assert fsync_calls[:2] == [raw_fd, metadata_fd]
|
||||
assert len(writer.metadata_path.read_text(encoding="utf-8").splitlines()) == 2
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_group_commit_timer_bounds_quiet_stream_rpo(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
|
||||
writer.open()
|
||||
writer.record(_mqtt_message("RealtimePath", b"one"))
|
||||
assert writer.metadata_path.read_bytes() == b""
|
||||
|
||||
writer.maybe_commit(
|
||||
writer._last_commit_monotonic # noqa: SLF001
|
||||
+ capture_module.GROUP_COMMIT_INTERVAL_SECONDS
|
||||
)
|
||||
|
||||
assert len(writer.metadata_path.read_text(encoding="utf-8").splitlines()) == 1
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_raw_fsync_failure_never_publishes_metadata_ahead_of_raw(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
|
||||
writer.open()
|
||||
writer.record(_mqtt_message("RealtimePath", b"one"))
|
||||
assert writer._raw is not None # noqa: SLF001
|
||||
raw_fd = writer._raw.fileno() # noqa: SLF001
|
||||
real_fsync = capture_module.os.fsync
|
||||
|
||||
def fail_raw_fsync(descriptor: int) -> None:
|
||||
if descriptor == raw_fd:
|
||||
raise OSError("synthetic raw fsync failure")
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(capture_module.os, "fsync", fail_raw_fsync)
|
||||
|
||||
with pytest.raises(OSError, match="synthetic raw fsync failure"):
|
||||
writer._commit_pending() # noqa: SLF001
|
||||
|
||||
assert writer.metadata_path.read_bytes() == b""
|
||||
# Restore durability primitive so the fixture can close normally.
|
||||
monkeypatch.setattr(capture_module.os, "fsync", real_fsync)
|
||||
writer.close()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import k1link.viewer.rrd_export as export_module
|
||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
from k1link.viewer.rrd_export import (
|
||||
RECORDED_POINTS_VISUALIZER_ID,
|
||||
RECORDED_ROOT_CONTAINER_ID,
|
||||
RECORDED_SPATIAL_VIEW_ID,
|
||||
SESSION_TIMELINE,
|
||||
RrdExportCancelled,
|
||||
RrdExportError,
|
||||
_recorded_blueprint,
|
||||
export_k1mqtt_to_rrd,
|
||||
recorded_blueprint_rrd,
|
||||
)
|
||||
|
||||
|
||||
def _point_payload(x: float) -> bytes:
|
||||
return struct.pack("<III", 16, 0, 0) + struct.pack(
|
||||
"<fffBBBB",
|
||||
x,
|
||||
-2.0,
|
||||
3.0,
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
40,
|
||||
)
|
||||
|
||||
|
||||
def _pose_payload(x: float) -> bytes:
|
||||
return struct.pack("<ffffffff", x, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def _write_capture(
|
||||
directory: Path,
|
||||
frames: list[tuple[str, bytes, int]],
|
||||
) -> Path:
|
||||
capture = directory / "mqtt.raw.k1mqtt"
|
||||
raw = bytearray(RAW_MAGIC)
|
||||
metadata: list[dict[str, object]] = []
|
||||
epoch_origin_ns = 1_784_124_315_000_000_000
|
||||
monotonic_origin_ns = 9_000_000_000
|
||||
for sequence, (topic, payload, session_time_ns) in enumerate(frames, start=1):
|
||||
topic_raw = topic.encode("utf-8")
|
||||
raw.extend(FRAME_HEADER.pack(len(topic_raw), len(payload)))
|
||||
raw.extend(topic_raw)
|
||||
raw.extend(payload)
|
||||
metadata.append(
|
||||
{
|
||||
"record_type": "message",
|
||||
"sequence": sequence,
|
||||
"received_at_epoch_ns": epoch_origin_ns + session_time_ns,
|
||||
"received_monotonic_ns": monotonic_origin_ns + session_time_ns,
|
||||
}
|
||||
)
|
||||
capture.write_bytes(raw)
|
||||
(directory / "mqtt.metadata.jsonl").write_text(
|
||||
"".join(json.dumps(item) + "\n" for item in metadata),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return capture
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _print_rrd_entity(path: Path, entity: str) -> str:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"rerun_cli",
|
||||
"rrd",
|
||||
"print",
|
||||
"-vvv",
|
||||
"--entity",
|
||||
entity,
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def test_recorded_blueprint_accumulates_point_frames_on_session_timeline() -> None:
|
||||
blueprint = _recorded_blueprint(
|
||||
RerunSceneSettings(
|
||||
point_size=7.5,
|
||||
palette="custom",
|
||||
custom_color="#112233",
|
||||
accumulation_seconds=18.5,
|
||||
show_points=False,
|
||||
show_trajectory=True,
|
||||
show_grid=False,
|
||||
)
|
||||
)
|
||||
spatial_view = blueprint.root_container.contents[0]
|
||||
visible_ranges = spatial_view.properties["VisibleTimeRanges"]
|
||||
line_grid = spatial_view.properties["LineGrid3D"]
|
||||
|
||||
assert visible_ranges.ranges.as_arrow_array().to_pylist() == [
|
||||
{
|
||||
"timeline": SESSION_TIMELINE,
|
||||
"range": {
|
||||
"start": -18_500_000_000,
|
||||
"end": 0,
|
||||
},
|
||||
}
|
||||
]
|
||||
assert line_grid.visible.as_arrow_array().to_pylist() == [False]
|
||||
point_behavior, point_visualizer = spatial_view.visualizer_overrides["/world/points"]
|
||||
trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"]
|
||||
assert point_behavior.visible.as_arrow_array().to_pylist() == [False]
|
||||
assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True]
|
||||
point_overrides = {
|
||||
str(batch.component_descriptor()): batch.as_arrow_array().to_pylist()
|
||||
for batch in point_visualizer.overrides
|
||||
}
|
||||
assert point_overrides == {
|
||||
"Points3D:colors": [0x112233FF],
|
||||
# Negative values are Rerun's encoding for screen-space UI points.
|
||||
"Points3D:radii": [-7.5],
|
||||
}
|
||||
|
||||
|
||||
def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> None:
|
||||
blueprint = _recorded_blueprint(
|
||||
RerunSceneSettings(accumulation_seconds=0.0, show_grid=True)
|
||||
)
|
||||
spatial_view = blueprint.root_container.contents[0]
|
||||
|
||||
assert "VisibleTimeRanges" not in spatial_view.properties
|
||||
point_behavior, point_visualizer = spatial_view.visualizer_overrides["/world/points"]
|
||||
trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"]
|
||||
assert point_behavior.visible.as_arrow_array().to_pylist() == [True]
|
||||
assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True]
|
||||
point_components = {
|
||||
str(batch.component_descriptor()) for batch in point_visualizer.overrides
|
||||
}
|
||||
assert point_components == {"Points3D:radii"}
|
||||
|
||||
|
||||
def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None:
|
||||
first = _recorded_blueprint(
|
||||
RerunSceneSettings(show_points=False, show_trajectory=True),
|
||||
include_initial_playback_state=False,
|
||||
)
|
||||
second = _recorded_blueprint(
|
||||
RerunSceneSettings(show_points=True, show_trajectory=False),
|
||||
include_initial_playback_state=False,
|
||||
)
|
||||
|
||||
assert not hasattr(first, "time_panel")
|
||||
assert not hasattr(second, "time_panel")
|
||||
assert first.root_container.id == second.root_container.id == RECORDED_ROOT_CONTAINER_ID
|
||||
first_view = first.root_container.contents[0]
|
||||
second_view = second.root_container.contents[0]
|
||||
assert first_view.id == second_view.id == RECORDED_SPATIAL_VIEW_ID
|
||||
|
||||
first_point_behavior, first_point_visualizer = first_view.visualizer_overrides[
|
||||
"/world/points"
|
||||
]
|
||||
second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[
|
||||
"/world/points"
|
||||
]
|
||||
first_trajectory = first_view.visualizer_overrides["/world/trajectory"]
|
||||
second_trajectory = second_view.visualizer_overrides["/world/trajectory"]
|
||||
assert first_point_visualizer.id == RECORDED_POINTS_VISUALIZER_ID
|
||||
assert second_point_visualizer.id == RECORDED_POINTS_VISUALIZER_ID
|
||||
assert first_point_behavior.visible.as_arrow_array().to_pylist() == [False]
|
||||
assert second_point_behavior.visible.as_arrow_array().to_pylist() == [True]
|
||||
assert first_trajectory.visible.as_arrow_array().to_pylist() == [True]
|
||||
assert second_trajectory.visible.as_arrow_array().to_pylist() == [False]
|
||||
|
||||
payload = recorded_blueprint_rrd(
|
||||
RerunSceneSettings(show_points=False, show_trajectory=False),
|
||||
recording_id="stable-recording",
|
||||
)
|
||||
assert b"PlayState" not in payload
|
||||
assert b"play_state" not in payload
|
||||
assert str(RECORDED_ROOT_CONTAINER_ID).encode() in payload
|
||||
assert str(RECORDED_SPATIAL_VIEW_ID).encode() in payload
|
||||
assert str(RECORDED_POINTS_VISUALIZER_ID).encode() in payload
|
||||
|
||||
|
||||
def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Path) -> None:
|
||||
capture = _write_capture(
|
||||
tmp_path,
|
||||
[
|
||||
("lixel/application/report/heartbeat", b"opaque", 0),
|
||||
("RealtimePointcloud", _point_payload(1.0), 100_000_000),
|
||||
("RealtimePath", _pose_payload(1.0), 600_000_000),
|
||||
("RealtimePointcloud", _point_payload(2.0), 900_000_000),
|
||||
("RealtimePath", _pose_payload(1.2), 1_200_000_000),
|
||||
("lixel/application/report/heartbeat", b"opaque-tail", 9_500_000_000),
|
||||
],
|
||||
)
|
||||
output = tmp_path / "session.rrd"
|
||||
|
||||
summary = export_k1mqtt_to_rrd(capture, output)
|
||||
|
||||
assert summary["source_messages"] == 6
|
||||
assert summary["decoded_messages"] == 4
|
||||
assert summary["point_frames"] == 2
|
||||
assert summary["pose_frames"] == 2
|
||||
assert summary["ignored_messages"] == 2
|
||||
assert summary["points"] == 2
|
||||
assert summary["trajectory_poses"] == 2
|
||||
assert summary["trajectory_updates"] == 2
|
||||
assert summary["session_origin_monotonic_ns"] == 9_000_000_000
|
||||
assert summary["timeline"] == "session_time"
|
||||
assert summary["timeline_start_ns"] == 0
|
||||
assert summary["timeline_end_ns"] == 1_200_000_000
|
||||
assert summary["timeline_span_ns"] == 1_200_000_000
|
||||
assert summary["first_decoded_time_ns"] == 100_000_000
|
||||
assert summary["last_decoded_time_ns"] == 1_200_000_000
|
||||
assert summary["source_sha256"] == _sha256(capture)
|
||||
assert summary["rrd_bytes"] == output.stat().st_size
|
||||
assert summary["rrd_sha256"] == _sha256(output)
|
||||
assert output.stat().st_size > 0
|
||||
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
|
||||
|
||||
origin_table = _print_rrd_entity(output, "/__mission_core/session_origin")
|
||||
assert "/__mission_core/session_origin" in origin_table
|
||||
assert "session_time" in origin_table
|
||||
assert "P0D" in origin_table
|
||||
assert "[true]" in origin_table
|
||||
|
||||
|
||||
def test_atomic_rename_failure_preserves_existing_recording(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capture = _write_capture(
|
||||
tmp_path,
|
||||
[("RealtimePointcloud", _point_payload(1.0), 0)],
|
||||
)
|
||||
output = tmp_path / "session.rrd"
|
||||
trusted = b"existing-good-recording"
|
||||
output.write_bytes(trusted)
|
||||
|
||||
def fail_replace(_source: Path, _destination: Path) -> None:
|
||||
raise OSError("synthetic rename failure")
|
||||
|
||||
monkeypatch.setattr(export_module.os, "replace", fail_replace)
|
||||
|
||||
with pytest.raises(RrdExportError, match="synthetic rename failure"):
|
||||
export_k1mqtt_to_rrd(capture, output)
|
||||
|
||||
assert output.read_bytes() == trusted
|
||||
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
|
||||
|
||||
|
||||
def test_missing_monotonic_metadata_never_replaces_existing_recording(tmp_path: Path) -> None:
|
||||
capture = _write_capture(
|
||||
tmp_path,
|
||||
[("RealtimePointcloud", _point_payload(1.0), 0)],
|
||||
)
|
||||
(tmp_path / "mqtt.metadata.jsonl").unlink()
|
||||
output = tmp_path / "session.rrd"
|
||||
output.write_bytes(b"existing-good-recording")
|
||||
|
||||
with pytest.raises(RrdExportError, match="received_monotonic_ns"):
|
||||
export_k1mqtt_to_rrd(capture, output)
|
||||
|
||||
assert output.read_bytes() == b"existing-good-recording"
|
||||
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
|
||||
|
||||
|
||||
def test_export_preserves_valid_prefix_before_crash_metadata_tail(tmp_path: Path) -> None:
|
||||
capture = _write_capture(
|
||||
tmp_path,
|
||||
[
|
||||
("RealtimePointcloud", _point_payload(1.0), 100_000_000),
|
||||
("RealtimePointcloud", _point_payload(2.0), 200_000_000),
|
||||
],
|
||||
)
|
||||
metadata_path = tmp_path / "mqtt.metadata.jsonl"
|
||||
first_line = metadata_path.read_text(encoding="utf-8").splitlines(keepends=True)[0]
|
||||
metadata_path.write_text(
|
||||
first_line + '{"record_type":"message"',
|
||||
encoding="utf-8",
|
||||
)
|
||||
output = tmp_path / "session.rrd"
|
||||
|
||||
summary = export_k1mqtt_to_rrd(capture, output)
|
||||
|
||||
assert summary["source_messages"] == 1
|
||||
assert summary["decoded_messages"] == 1
|
||||
assert summary["point_frames"] == 1
|
||||
assert summary["timeline_end_ns"] == 0
|
||||
assert output.stat().st_size > 0
|
||||
|
||||
|
||||
def test_export_honors_cooperative_cancellation_without_publishing(tmp_path: Path) -> None:
|
||||
capture = _write_capture(
|
||||
tmp_path,
|
||||
[("RealtimePointcloud", _point_payload(1.0), 0)],
|
||||
)
|
||||
output = tmp_path / "session.rrd"
|
||||
output.write_bytes(b"trusted-existing-recording")
|
||||
cancelled = threading.Event()
|
||||
cancelled.set()
|
||||
|
||||
with pytest.raises(RrdExportCancelled):
|
||||
export_k1mqtt_to_rrd(capture, output, cancel_event=cancelled)
|
||||
|
||||
assert output.read_bytes() == b"trusted-existing-recording"
|
||||
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,357 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||
from k1link.sessions import (
|
||||
SessionRecordingMaterializer,
|
||||
SessionRecordingPreparationManager,
|
||||
SessionStore,
|
||||
)
|
||||
from k1link.web import app as app_module
|
||||
from k1link.web.session_api import build_session_router
|
||||
|
||||
|
||||
def _make_completed_session(root: Path, session_id: str) -> Path:
|
||||
capture = root / session_id / "captures" / "mqtt_live"
|
||||
capture.mkdir(parents=True)
|
||||
topics = (
|
||||
"lixel/application/report/lio_pcl",
|
||||
"lixel/application/report/lio_pose",
|
||||
)
|
||||
raw = bytearray(RAW_MAGIC)
|
||||
metadata: list[dict[str, object]] = []
|
||||
for sequence, topic in enumerate(topics, start=1):
|
||||
encoded_topic = topic.encode("utf-8")
|
||||
payload = b"fixture"
|
||||
frame_offset = len(raw)
|
||||
raw.extend(FRAME_HEADER.pack(len(encoded_topic), len(payload)))
|
||||
raw.extend(encoded_topic)
|
||||
raw.extend(payload)
|
||||
metadata.append(
|
||||
{
|
||||
"record_type": "message",
|
||||
"sequence": sequence,
|
||||
"received_at_epoch_ns": 1_000_000_000 + sequence,
|
||||
"received_monotonic_ns": 2_000_000_000 + sequence,
|
||||
"topic": topic,
|
||||
"payload_bytes": len(payload),
|
||||
"raw_frame_offset": frame_offset,
|
||||
"raw_payload_offset": frame_offset + FRAME_HEADER.size + len(encoded_topic),
|
||||
"raw_frame_bytes": FRAME_HEADER.size + len(encoded_topic) + len(payload),
|
||||
}
|
||||
)
|
||||
(capture / "mqtt.raw.k1mqtt").write_bytes(raw)
|
||||
metadata_path = capture / "mqtt.metadata.jsonl"
|
||||
metadata_path.write_text(
|
||||
"".join(json.dumps(record) + "\n" for record in metadata),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(capture / "mqtt.summary.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"created_at_utc": "2026-07-16T20:56:32.699Z",
|
||||
"completed_at_utc": "2026-07-16T20:57:02.699Z",
|
||||
"capture_elapsed_seconds": 30.0,
|
||||
"stop_reason": "external_stop",
|
||||
"error": None,
|
||||
"message_count": 2,
|
||||
"raw_bytes": len(raw),
|
||||
"topic_counts": {topic: 1 for topic in topics},
|
||||
"artifact_hashes": {
|
||||
"raw_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"metadata_jsonl_sha256": hashlib.sha256(metadata_path.read_bytes()).hexdigest(),
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return capture.parents[1]
|
||||
|
||||
|
||||
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
|
||||
return next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if isinstance(route, APIRoute) and route.path == path and method in route.methods
|
||||
)
|
||||
|
||||
|
||||
def test_list_refresh_discovers_new_completed_session_without_server_restart(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
refresh_calls = 0
|
||||
|
||||
def refresh() -> tuple[str, ...]:
|
||||
nonlocal refresh_calls
|
||||
refresh_calls += 1
|
||||
return store.import_legacy_viewer_live(sessions)
|
||||
|
||||
router = build_session_router(store, catalog_refresher=refresh)
|
||||
list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")
|
||||
|
||||
assert list_route(limit=20, cursor=None) == {"items": []}
|
||||
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
listing = list_route(limit=20, cursor=None)
|
||||
|
||||
assert refresh_calls == 2
|
||||
assert [item["id"] for item in listing["items"]] == [session.name]
|
||||
|
||||
|
||||
def test_detail_and_replay_refresh_catalog_before_lookup(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
refresh_calls = 0
|
||||
|
||||
def refresh() -> tuple[str, ...]:
|
||||
nonlocal refresh_calls
|
||||
refresh_calls += 1
|
||||
return store.import_legacy_viewer_live(sessions)
|
||||
|
||||
router = build_session_router(store, catalog_refresher=refresh)
|
||||
detail_route = _endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}",
|
||||
"GET",
|
||||
)
|
||||
replay_route = _endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}/replay",
|
||||
"POST",
|
||||
)
|
||||
|
||||
detail = detail_route(session_id=session.name)
|
||||
replay = asyncio.run(replay_route(session_id=session.name, request=None))
|
||||
|
||||
assert detail["session_id"] == session.name
|
||||
assert replay["launch"]["session_id"] == session.name
|
||||
assert refresh_calls == 2
|
||||
|
||||
|
||||
def test_catalog_refresh_failure_is_a_stable_service_error(tmp_path: Path) -> None:
|
||||
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
|
||||
|
||||
def fail_refresh() -> None:
|
||||
raise OSError("private local path must not escape")
|
||||
|
||||
router = build_session_router(store, catalog_refresher=fail_refresh)
|
||||
list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
list_route(limit=20, cursor=None)
|
||||
assert error.value.status_code == 503
|
||||
assert "private local path" not in str(error.value.detail)
|
||||
|
||||
|
||||
def test_startup_warmup_enqueues_every_finalized_replayable_session(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
payload = b"startup-prepared-recording"
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||
)
|
||||
monkeypatch.setattr(app_module, "session_store", store)
|
||||
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||
try:
|
||||
enqueued = app_module.enqueue_replayable_recordings()
|
||||
deadline = time.monotonic() + 2
|
||||
snapshot = manager.status(session.name)
|
||||
while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
snapshot = manager.status(session.name)
|
||||
|
||||
assert enqueued == (session.name,)
|
||||
assert snapshot is not None
|
||||
assert snapshot.state == "ready"
|
||||
assert snapshot.recording is not None
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_reconciliation_skips_one_stale_session_and_prepares_later_valid_session(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
good = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
stale = _make_completed_session(sessions, "20260716T205633Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
(stale / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt").unlink()
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
payload = b"prepared-after-stale-row"
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||
)
|
||||
monkeypatch.setattr(app_module, "session_store", store)
|
||||
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||
try:
|
||||
enqueued = app_module.enqueue_replayable_recordings()
|
||||
deadline = time.monotonic() + 2
|
||||
snapshot = manager.status(good.name)
|
||||
while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
snapshot = manager.status(good.name)
|
||||
|
||||
assert enqueued == (good.name,)
|
||||
assert manager.status(stale.name) is None
|
||||
assert snapshot is not None and snapshot.state == "ready"
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_reconciler_requeues_only_a_restart_interrupted_job(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
calls = 0
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
started.set()
|
||||
assert release.wait(timeout=2)
|
||||
payload = f"restart-{calls}".encode()
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||
)
|
||||
monkeypatch.setattr(app_module, "session_store", store)
|
||||
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||
try:
|
||||
app_module.enqueue_replayable_recordings()
|
||||
assert started.wait(timeout=1)
|
||||
manager.close(timeout=0.001)
|
||||
manager.start()
|
||||
release.set()
|
||||
|
||||
deadline = time.monotonic() + 2
|
||||
snapshot = manager.status(session.name)
|
||||
while (
|
||||
snapshot is None or snapshot.state != "cancelled"
|
||||
) and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
snapshot = manager.status(session.name)
|
||||
assert snapshot is not None and snapshot.state == "cancelled"
|
||||
interrupted_id = snapshot.preparation_id
|
||||
|
||||
app_module.enqueue_replayable_recordings()
|
||||
deadline = time.monotonic() + 2
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = manager.status(session.name)
|
||||
if snapshot is not None and snapshot.state == "ready":
|
||||
break
|
||||
time.sleep(0.005)
|
||||
assert snapshot is not None and snapshot.state == "ready"
|
||||
assert snapshot.preparation_id != interrupted_id
|
||||
assert calls == 2
|
||||
finally:
|
||||
release.set()
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_reconciler_does_not_loop_retry_a_genuine_failed_job(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
calls = 0
|
||||
|
||||
def exporter(_source: Path, _destination: Path) -> dict[str, object]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise OSError("genuine export failure")
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||
)
|
||||
monkeypatch.setattr(app_module, "session_store", store)
|
||||
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||
try:
|
||||
app_module.enqueue_replayable_recordings()
|
||||
deadline = time.monotonic() + 2
|
||||
snapshot = manager.status(session.name)
|
||||
while (
|
||||
snapshot is None or snapshot.state != "failed"
|
||||
) and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
snapshot = manager.status(session.name)
|
||||
assert snapshot is not None and snapshot.state == "failed"
|
||||
failed_id = snapshot.preparation_id
|
||||
|
||||
app_module.enqueue_replayable_recordings()
|
||||
time.sleep(0.05)
|
||||
unchanged = manager.status(session.name)
|
||||
assert unchanged is not None
|
||||
assert unchanged.preparation_id == failed_id
|
||||
assert unchanged.state == "failed"
|
||||
assert calls == 1
|
||||
finally:
|
||||
manager.close()
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.sessions.models import ReplayCommand
|
||||
from k1link.sessions.preparation import (
|
||||
RecordingPreparationSnapshot,
|
||||
SessionRecordingPreparationManager,
|
||||
)
|
||||
from k1link.sessions.recording import RecordingMaterializationError, SessionRecordingMaterializer
|
||||
|
||||
|
||||
def _command(root: Path, session_id: str = "20260717T100000Z_viewer_live") -> ReplayCommand:
|
||||
root.mkdir(parents=True)
|
||||
source = root / "mqtt.raw.k1mqtt"
|
||||
metadata = root / "mqtt.metadata.jsonl"
|
||||
source.write_bytes(b"native-session-source")
|
||||
metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8")
|
||||
return ReplayCommand(
|
||||
session_id=session_id,
|
||||
source_path=source,
|
||||
allowed_root=root,
|
||||
session_root=root,
|
||||
replay_byte_length=source.stat().st_size,
|
||||
metadata_byte_length=metadata.stat().st_size,
|
||||
expected_source_sha256=None,
|
||||
speed=1.0,
|
||||
loop=False,
|
||||
)
|
||||
|
||||
|
||||
def _summary(source: Path, destination: Path, payload: bytes) -> dict[str, object]:
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1_000_000_000,
|
||||
}
|
||||
|
||||
|
||||
def _wait_for_state(
|
||||
manager: SessionRecordingPreparationManager,
|
||||
session_id: str,
|
||||
states: set[str],
|
||||
timeout: float = 2.0,
|
||||
) -> RecordingPreparationSnapshot:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = manager.status(session_id)
|
||||
if snapshot is not None and snapshot.state in states:
|
||||
return snapshot
|
||||
time.sleep(0.005)
|
||||
raise AssertionError(f"preparation did not reach {states}")
|
||||
|
||||
|
||||
def test_manager_returns_quick_job_deduplicates_and_reports_monotonic_progress(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
calls = 0
|
||||
|
||||
def exporter(
|
||||
source: Path,
|
||||
destination: Path,
|
||||
*,
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: Callable[[], None] | None = None,
|
||||
) -> dict[str, object]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
deadline = time.monotonic() + 2
|
||||
while not release.wait(timeout=0.005):
|
||||
assert cancel_event is None or not cancel_event.is_set()
|
||||
if activity_callback is not None:
|
||||
activity_callback()
|
||||
assert time.monotonic() < deadline
|
||||
return _summary(source, destination, b"prepared-recording")
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter),
|
||||
heartbeat_interval_seconds=0.01,
|
||||
)
|
||||
try:
|
||||
before = time.monotonic()
|
||||
first = manager.enqueue(command)
|
||||
elapsed = time.monotonic() - before
|
||||
duplicate = manager.enqueue(command)
|
||||
|
||||
assert elapsed < 0.1
|
||||
assert duplicate.preparation_id == first.preparation_id
|
||||
assert started.wait(timeout=1)
|
||||
exporting = _wait_for_state(manager, command.session_id, {"exporting"})
|
||||
time.sleep(0.03)
|
||||
heartbeat = manager.status(command.session_id)
|
||||
assert heartbeat is not None
|
||||
assert heartbeat.updated_at_utc > exporting.updated_at_utc
|
||||
assert heartbeat.progress >= exporting.progress
|
||||
assert heartbeat.cancellable is True
|
||||
release.set()
|
||||
ready = _wait_for_state(manager, command.session_id, {"ready"})
|
||||
|
||||
assert calls == 1
|
||||
assert exporting.progress <= ready.progress == 1.0
|
||||
assert ready.recording is not None
|
||||
assert ready.updated_at_utc.endswith("Z")
|
||||
finally:
|
||||
release.set()
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_manager_failure_is_retryable_and_retry_publishes_new_job(tmp_path: Path) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
calls = 0
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("simulated converter failure")
|
||||
return _summary(source, destination, b"retry-recording")
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
)
|
||||
try:
|
||||
first = manager.enqueue(command)
|
||||
failed = _wait_for_state(manager, command.session_id, {"failed"})
|
||||
retry = manager.enqueue(command, retry_failed=True)
|
||||
ready = _wait_for_state(manager, command.session_id, {"ready"})
|
||||
|
||||
assert failed.retryable is True
|
||||
assert failed.error == "Не удалось подготовить запись сессии."
|
||||
assert retry.preparation_id != first.preparation_id
|
||||
assert ready.preparation_id == retry.preparation_id
|
||||
assert calls == 2
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_manager_cancels_queued_job_without_exporting_it(tmp_path: Path) -> None:
|
||||
first = _command(tmp_path / "first")
|
||||
second = replace(
|
||||
_command(tmp_path / "second"),
|
||||
session_id="20260717T100001Z_viewer_live",
|
||||
)
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
calls = 0
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
assert release.wait(timeout=2)
|
||||
return _summary(source, destination, b"recording")
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
)
|
||||
try:
|
||||
manager.enqueue(first)
|
||||
assert started.wait(timeout=1)
|
||||
manager.enqueue(second)
|
||||
assert manager.cancel(second.session_id) is True
|
||||
cancelled = _wait_for_state(manager, second.session_id, {"cancelled"})
|
||||
release.set()
|
||||
_wait_for_state(manager, first.session_id, {"ready"})
|
||||
time.sleep(0.02)
|
||||
|
||||
assert cancelled.cancellable is False
|
||||
assert calls == 1
|
||||
finally:
|
||||
release.set()
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_manager_can_restart_across_repeated_application_lifespans(tmp_path: Path) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
calls = 0
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return _summary(source, destination, b"restartable-recording")
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
)
|
||||
try:
|
||||
manager.enqueue(command)
|
||||
_wait_for_state(manager, command.session_id, {"ready"})
|
||||
manager.close()
|
||||
manager.start()
|
||||
|
||||
resumed = manager.enqueue(command)
|
||||
|
||||
assert resumed.state == "ready"
|
||||
assert calls == 1
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_noncooperative_exporter_has_no_fake_heartbeat_and_restart_waits_for_old_writer(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = _command(tmp_path / "first")
|
||||
second = replace(
|
||||
_command(tmp_path / "second"),
|
||||
session_id="20260717T100002Z_viewer_live",
|
||||
)
|
||||
first_started = threading.Event()
|
||||
release_first = threading.Event()
|
||||
active = 0
|
||||
max_active = 0
|
||||
calls = 0
|
||||
guard = threading.Lock()
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
nonlocal active, calls, max_active
|
||||
with guard:
|
||||
calls += 1
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
call = calls
|
||||
try:
|
||||
if call == 1:
|
||||
first_started.set()
|
||||
assert release_first.wait(timeout=2)
|
||||
return _summary(source, destination, f"recording-{call}".encode())
|
||||
finally:
|
||||
with guard:
|
||||
active -= 1
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter),
|
||||
heartbeat_interval_seconds=0.01,
|
||||
)
|
||||
try:
|
||||
manager.enqueue(first)
|
||||
assert first_started.wait(timeout=1)
|
||||
blocked = _wait_for_state(manager, first.session_id, {"exporting"})
|
||||
time.sleep(0.03)
|
||||
unchanged = manager.status(first.session_id)
|
||||
assert unchanged is not None
|
||||
assert unchanged.updated_at_utc == blocked.updated_at_utc
|
||||
assert unchanged.cancellable is False
|
||||
|
||||
manager.close(timeout=0.001)
|
||||
manager.start()
|
||||
queued = manager.enqueue(second)
|
||||
assert queued.state == "queued"
|
||||
time.sleep(0.03)
|
||||
assert calls == 1
|
||||
|
||||
release_first.set()
|
||||
ready = _wait_for_state(manager, second.session_id, {"ready"})
|
||||
assert ready.recording is not None
|
||||
assert calls == 2
|
||||
assert max_active == 1
|
||||
finally:
|
||||
release_first.set()
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_reconciler_retry_does_not_replace_operator_cancel_across_restart(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
calls = 0
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
assert release.wait(timeout=2)
|
||||
return _summary(source, destination, b"operator-cancelled")
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
)
|
||||
try:
|
||||
original = manager.enqueue(command)
|
||||
assert started.wait(timeout=1)
|
||||
assert manager.cancel(
|
||||
command.session_id,
|
||||
preparation_id=original.preparation_id,
|
||||
)
|
||||
manager.close(timeout=0.001)
|
||||
manager.start()
|
||||
release.set()
|
||||
cancelled = _wait_for_state(manager, command.session_id, {"cancelled"})
|
||||
|
||||
reconciled = manager.enqueue(command, retry_interrupted=True)
|
||||
|
||||
assert reconciled.preparation_id == cancelled.preparation_id
|
||||
assert reconciled.state == "cancelled"
|
||||
assert calls == 1
|
||||
finally:
|
||||
release.set()
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_shared_preparation_command_ignores_per_request_playback_policy(tmp_path: Path) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
started.set()
|
||||
assert release.wait(timeout=2)
|
||||
return _summary(source, destination, b"recording")
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
)
|
||||
try:
|
||||
first = manager.enqueue(replace(command, speed=2.0, loop=True))
|
||||
assert started.wait(timeout=1)
|
||||
duplicate = manager.enqueue(replace(command, speed=7.0, loop=False))
|
||||
|
||||
assert duplicate.preparation_id == first.preparation_id
|
||||
assert duplicate.command.speed == 1.0
|
||||
assert duplicate.command.loop is False
|
||||
finally:
|
||||
release.set()
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_launch_reservation_blocks_eviction_until_lease_expires(tmp_path: Path) -> None:
|
||||
first = _command(tmp_path / "first")
|
||||
second = replace(
|
||||
_command(tmp_path / "second"),
|
||||
session_id="20260717T100003Z_viewer_live",
|
||||
)
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
return _summary(source, destination, b"R" * (40 * 1024))
|
||||
|
||||
materializer = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=exporter,
|
||||
cache_max_bytes=50 * 1024,
|
||||
free_space_reserve_bytes=0,
|
||||
)
|
||||
manager = SessionRecordingPreparationManager(materializer)
|
||||
try:
|
||||
first_recording = materializer.materialize(first)
|
||||
resolved = manager.resolve_cached(first)
|
||||
assert resolved is not None and resolved.state == "ready"
|
||||
reserved = manager.reserve_cached(first, lease_seconds=0.05)
|
||||
assert reserved is not None and reserved.recording is not None
|
||||
|
||||
with pytest.raises(RecordingMaterializationError, match="cache quota"):
|
||||
materializer.materialize(second)
|
||||
assert first_recording.path.exists()
|
||||
|
||||
time.sleep(0.08)
|
||||
second_recording = materializer.materialize(second)
|
||||
assert second_recording.path.exists()
|
||||
assert not first_recording.path.exists()
|
||||
finally:
|
||||
manager.close()
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import k1link.sessions.recording as recording_module
|
||||
from k1link.sessions.models import ReplayCommand
|
||||
from k1link.sessions.recording import (
|
||||
CACHE_SCHEMA,
|
||||
RERUN_RECORDING_MEDIA_TYPE,
|
||||
RecordingMaterializationError,
|
||||
SessionRecordingMaterializer,
|
||||
)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
class FakeExporter:
|
||||
def __init__(self, *, delay_seconds: float = 0.0) -> None:
|
||||
self.calls = 0
|
||||
self.delay_seconds = delay_seconds
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self.calls += 1
|
||||
sequence = self.calls
|
||||
if self.delay_seconds:
|
||||
time.sleep(self.delay_seconds)
|
||||
destination.write_bytes(b"RRD" + sequence.to_bytes(2, "big") + source.read_bytes())
|
||||
return {
|
||||
"source_sha256": _sha256(source),
|
||||
"rrd_sha256": _sha256(destination),
|
||||
"rrd_bytes": destination.stat().st_size,
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 2_500_000_000,
|
||||
}
|
||||
|
||||
|
||||
def _command(tmp_path: Path) -> ReplayCommand:
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
source = tmp_path / "mqtt.raw.k1mqtt"
|
||||
source.write_bytes(b"native-source-recording")
|
||||
metadata = tmp_path / "mqtt.metadata.jsonl"
|
||||
metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8")
|
||||
return ReplayCommand(
|
||||
session_id="20260716T205632Z_viewer_live",
|
||||
source_path=source,
|
||||
allowed_root=tmp_path,
|
||||
session_root=tmp_path,
|
||||
replay_byte_length=source.stat().st_size,
|
||||
metadata_byte_length=metadata.stat().st_size,
|
||||
expected_source_sha256=None,
|
||||
speed=1.0,
|
||||
loop=False,
|
||||
)
|
||||
|
||||
|
||||
def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Path) -> None:
|
||||
command = _command(tmp_path)
|
||||
exporter = FakeExporter()
|
||||
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
|
||||
first = materializer.materialize(command)
|
||||
second = materializer.materialize(command)
|
||||
|
||||
assert first == second
|
||||
assert exporter.calls == 1
|
||||
assert first.media_type == RERUN_RECORDING_MEDIA_TYPE
|
||||
assert first.timeline == "session_time"
|
||||
assert first.timeline_start_ns == 0
|
||||
assert first.timeline_end_ns == 2_500_000_000
|
||||
assert first.path.is_relative_to(materializer.recordings_root)
|
||||
assert first.path.stat().st_mode & 0o777 == 0o600
|
||||
sidecar = first.path.with_name("scene.rrd.cache.json")
|
||||
assert sidecar.stat().st_mode & 0o777 == 0o600
|
||||
document = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
assert document["schema_version"] == CACHE_SCHEMA
|
||||
assert "source_path" not in document
|
||||
assert "recording_path" not in document
|
||||
assert str(tmp_path) not in sidecar.read_text(encoding="utf-8")
|
||||
|
||||
after_restart = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=exporter,
|
||||
).materialize(command)
|
||||
assert after_restart == first
|
||||
assert exporter.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"obsolete_schema",
|
||||
[
|
||||
"missioncore.derived-rerun-recording-cache/v1",
|
||||
"missioncore.derived-rerun-recording-cache/v2",
|
||||
"missioncore.derived-rerun-recording-cache/v4",
|
||||
"missioncore.derived-rerun-recording-cache/v5",
|
||||
],
|
||||
)
|
||||
def test_materializer_rebuilds_incompatible_recording_cache_schema(
|
||||
tmp_path: Path,
|
||||
obsolete_schema: str,
|
||||
) -> None:
|
||||
command = _command(tmp_path)
|
||||
exporter = FakeExporter()
|
||||
private_root = tmp_path / "private"
|
||||
first = SessionRecordingMaterializer(private_root, exporter=exporter).materialize(command)
|
||||
sidecar = first.path.with_name("scene.rrd.cache.json")
|
||||
document = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
document["schema_version"] = obsolete_schema
|
||||
sidecar.write_text(json.dumps(document), encoding="utf-8")
|
||||
|
||||
rebuilt = SessionRecordingMaterializer(private_root, exporter=exporter).materialize(command)
|
||||
|
||||
assert exporter.calls == 2
|
||||
assert rebuilt.path == first.path
|
||||
assert json.loads(sidecar.read_text(encoding="utf-8"))["schema_version"] == CACHE_SCHEMA
|
||||
|
||||
|
||||
def test_failed_rebuild_preserves_previously_published_cache(tmp_path: Path) -> None:
|
||||
command = _command(tmp_path)
|
||||
exporter = FakeExporter()
|
||||
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
first = materializer.materialize(command)
|
||||
published_bytes = first.path.read_bytes()
|
||||
sidecar = first.path.with_name("scene.rrd.cache.json")
|
||||
published_sidecar = sidecar.read_bytes()
|
||||
command.source_path.write_bytes(b"new-source-that-requires-rebuild")
|
||||
changed = replace(command, replay_byte_length=command.source_path.stat().st_size)
|
||||
|
||||
def fail_export(_source: Path, destination: Path) -> dict[str, object]:
|
||||
destination.write_bytes(b"incomplete-candidate")
|
||||
raise OSError("simulated export failure")
|
||||
|
||||
failing = SessionRecordingMaterializer(tmp_path / "private", exporter=fail_export)
|
||||
with pytest.raises(RecordingMaterializationError):
|
||||
failing.materialize(changed)
|
||||
|
||||
assert first.path.read_bytes() == published_bytes
|
||||
assert sidecar.read_bytes() == published_sidecar
|
||||
assert not tuple(first.path.parent.glob(".scene.*.candidate.rrd"))
|
||||
|
||||
|
||||
def test_materializer_rebuilds_when_source_or_recording_changes(tmp_path: Path) -> None:
|
||||
command = _command(tmp_path)
|
||||
exporter = FakeExporter()
|
||||
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
|
||||
first = materializer.materialize(command)
|
||||
original_recording_stat = first.path.stat()
|
||||
corrupted = bytearray(first.path.read_bytes())
|
||||
corrupted[-1] ^= 0x01
|
||||
first.path.write_bytes(corrupted)
|
||||
os.utime(
|
||||
first.path,
|
||||
ns=(original_recording_stat.st_atime_ns, original_recording_stat.st_mtime_ns),
|
||||
)
|
||||
|
||||
repaired = materializer.materialize(command)
|
||||
assert exporter.calls == 2
|
||||
assert repaired.sha256 == _sha256(repaired.path)
|
||||
assert repaired.sha256 != hashlib.sha256(corrupted).hexdigest()
|
||||
|
||||
command.source_path.write_bytes(b"new-native-source")
|
||||
command = replace(command, replay_byte_length=command.source_path.stat().st_size)
|
||||
updated = materializer.materialize(command)
|
||||
assert exporter.calls == 3
|
||||
assert updated.source_sha256 == _sha256(command.source_path)
|
||||
|
||||
|
||||
def test_concurrent_materialization_exports_one_recording(tmp_path: Path) -> None:
|
||||
command = _command(tmp_path)
|
||||
exporter = FakeExporter(delay_seconds=0.05)
|
||||
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
recordings = tuple(executor.map(materializer.materialize, [command] * 8))
|
||||
|
||||
assert exporter.calls == 1
|
||||
assert len({recording.sha256 for recording in recordings}) == 1
|
||||
assert len({recording.path for recording in recordings}) == 1
|
||||
|
||||
|
||||
def test_materializer_never_follows_cache_symlinks_outside_private_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
command = _command(tmp_path)
|
||||
exporter = FakeExporter()
|
||||
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
session_cache = materializer.recordings_root / command.session_id
|
||||
session_cache.symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(RecordingMaterializationError, match="must not be a symlink"):
|
||||
materializer.materialize(command)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
session_cache.unlink()
|
||||
session_cache.mkdir()
|
||||
outside_recording = outside / "scene.rrd"
|
||||
outside_recording.write_bytes(b"do-not-touch")
|
||||
(session_cache / "scene.rrd").symlink_to(outside_recording)
|
||||
|
||||
recording = materializer.materialize(command)
|
||||
|
||||
assert recording.path.read_bytes().startswith(b"RRD")
|
||||
assert outside_recording.read_bytes() == b"do-not-touch"
|
||||
|
||||
|
||||
def test_materializer_revalidates_prepared_source_without_following_replaced_symlink(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
exporter = FakeExporter()
|
||||
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
outside = tmp_path / "outside.k1mqtt"
|
||||
outside.write_bytes(command.source_path.read_bytes())
|
||||
command.source_path.unlink()
|
||||
command.source_path.symlink_to(outside)
|
||||
|
||||
with pytest.raises(RecordingMaterializationError, match="missing or unsafe"):
|
||||
materializer.materialize(command)
|
||||
|
||||
assert exporter.calls == 0
|
||||
assert outside.read_bytes() == b"native-source-recording"
|
||||
|
||||
|
||||
def test_materializer_exports_only_validated_prefix_before_crash_tail(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
committed_raw_bytes = command.replay_byte_length
|
||||
committed_metadata_bytes = command.metadata_byte_length
|
||||
with command.source_path.open("ab") as stream:
|
||||
stream.write(b"uncommitted-raw-tail")
|
||||
metadata = command.source_path.with_name("mqtt.metadata.jsonl")
|
||||
with metadata.open("ab") as stream:
|
||||
stream.write(b'{"record_type":"message"')
|
||||
observed: list[tuple[int, int]] = []
|
||||
|
||||
class PrefixExporter(FakeExporter):
|
||||
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||
observed.append(
|
||||
(source.stat().st_size, source.with_name("mqtt.metadata.jsonl").stat().st_size)
|
||||
)
|
||||
return super().__call__(source, destination)
|
||||
|
||||
exporter = PrefixExporter()
|
||||
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
|
||||
recording = materializer.materialize(command)
|
||||
|
||||
assert observed == [(committed_raw_bytes, committed_metadata_bytes)]
|
||||
assert recording.source_sha256 == hashlib.sha256(b"native-source-recording").hexdigest()
|
||||
assert command.source_path.read_bytes().endswith(b"uncommitted-raw-tail")
|
||||
|
||||
|
||||
def test_global_singleflight_bounds_exports_across_different_sessions(tmp_path: Path) -> None:
|
||||
first = _command(tmp_path / "first")
|
||||
second = replace(
|
||||
_command(tmp_path / "second"),
|
||||
session_id="20260716T205633Z_viewer_live",
|
||||
)
|
||||
|
||||
class ConcurrencyExporter(FakeExporter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.active = 0
|
||||
self.max_active = 0
|
||||
|
||||
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
try:
|
||||
time.sleep(0.03)
|
||||
return super().__call__(source, destination)
|
||||
finally:
|
||||
with self._lock:
|
||||
self.active -= 1
|
||||
|
||||
exporter = ConcurrencyExporter()
|
||||
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
tuple(executor.map(materializer.materialize, (first, second)))
|
||||
|
||||
assert exporter.calls == 2
|
||||
assert exporter.max_active == 1
|
||||
|
||||
|
||||
def test_cross_process_file_lock_serializes_independent_materializer_instances(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = _command(tmp_path / "first")
|
||||
second = replace(
|
||||
_command(tmp_path / "second"),
|
||||
session_id="20260716T205634Z_viewer_live",
|
||||
)
|
||||
|
||||
class ConcurrencyExporter(FakeExporter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.active = 0
|
||||
self.max_active = 0
|
||||
|
||||
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
try:
|
||||
time.sleep(0.03)
|
||||
return super().__call__(source, destination)
|
||||
finally:
|
||||
with self._lock:
|
||||
self.active -= 1
|
||||
|
||||
exporter = ConcurrencyExporter()
|
||||
private_root = tmp_path / "private"
|
||||
first_materializer = SessionRecordingMaterializer(private_root, exporter=exporter)
|
||||
second_materializer = SessionRecordingMaterializer(private_root, exporter=exporter)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
first_future = executor.submit(first_materializer.materialize, first)
|
||||
second_future = executor.submit(second_materializer.materialize, second)
|
||||
assert first_future.result(timeout=2).path.exists()
|
||||
assert second_future.result(timeout=2).path.exists()
|
||||
|
||||
assert exporter.calls == 2
|
||||
assert exporter.max_active == 1
|
||||
|
||||
|
||||
def test_export_scavenges_confined_crash_candidates_before_quota_check(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
private_root = tmp_path / "private"
|
||||
materializer = SessionRecordingMaterializer(
|
||||
private_root,
|
||||
exporter=FakeExporter(),
|
||||
cache_max_bytes=32 * 1024,
|
||||
free_space_reserve_bytes=0,
|
||||
)
|
||||
session_cache = materializer.recordings_root / command.session_id
|
||||
session_cache.mkdir()
|
||||
stale_candidate = session_cache / ".scene.deadbeef.candidate.rrd"
|
||||
stale_candidate.write_bytes(b"x" * (24 * 1024))
|
||||
stale_export_temp = session_cache / "..scene.deadbeef.candidate.rrd.uuid.tmp"
|
||||
stale_export_temp.write_bytes(b"x" * 1024)
|
||||
stale_stage = session_cache / ".source.deadbeef.tmp"
|
||||
stale_stage.mkdir()
|
||||
(stale_stage / "mqtt.raw.k1mqtt").write_bytes(b"x" * 1024)
|
||||
|
||||
recording = materializer.materialize(command)
|
||||
|
||||
assert recording.path.exists()
|
||||
assert not stale_candidate.exists()
|
||||
assert not stale_export_temp.exists()
|
||||
assert not stale_stage.exists()
|
||||
|
||||
|
||||
def test_cached_recording_and_response_lease_do_not_wait_for_other_export(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = _command(tmp_path / "first")
|
||||
second = replace(
|
||||
_command(tmp_path / "second"),
|
||||
session_id="20260716T205633Z_viewer_live",
|
||||
)
|
||||
|
||||
class BlockingSecondExporter(FakeExporter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.second_started = threading.Event()
|
||||
self.release_second = threading.Event()
|
||||
|
||||
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||
with self._lock:
|
||||
next_call = self.calls + 1
|
||||
if next_call == 2:
|
||||
self.second_started.set()
|
||||
if not self.release_second.wait(timeout=2):
|
||||
raise AssertionError("test did not release the blocked export")
|
||||
return super().__call__(source, destination)
|
||||
|
||||
exporter = BlockingSecondExporter()
|
||||
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||
first_recording = materializer.materialize(first)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
exporting = executor.submit(materializer.materialize, second)
|
||||
assert exporter.second_started.wait(timeout=1)
|
||||
|
||||
cached = executor.submit(materializer.materialize, first).result(timeout=0.2)
|
||||
pinned, release = executor.submit(
|
||||
materializer.materialize_pinned,
|
||||
first,
|
||||
).result(timeout=0.2)
|
||||
|
||||
assert cached == first_recording
|
||||
assert pinned == first_recording
|
||||
release()
|
||||
exporter.release_second.set()
|
||||
assert exporting.result(timeout=1).session_id == second.session_id
|
||||
|
||||
|
||||
def test_cache_quota_evicts_lru_derived_recording_without_deleting_raw(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = _command(tmp_path / "first")
|
||||
second = replace(
|
||||
_command(tmp_path / "second"),
|
||||
session_id="20260716T205633Z_viewer_live",
|
||||
)
|
||||
|
||||
class SizedExporter(FakeExporter):
|
||||
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self.calls += 1
|
||||
destination.write_bytes(b"R" * (40 * 1024))
|
||||
return {
|
||||
"source_sha256": _sha256(source),
|
||||
"rrd_sha256": _sha256(destination),
|
||||
"rrd_bytes": destination.stat().st_size,
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
materializer = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=SizedExporter(),
|
||||
cache_max_bytes=50 * 1024,
|
||||
free_space_reserve_bytes=0,
|
||||
)
|
||||
first_recording = materializer.materialize(first)
|
||||
first_source_bytes = first.source_path.read_bytes()
|
||||
|
||||
second_recording = materializer.materialize(second)
|
||||
|
||||
assert not first_recording.path.exists()
|
||||
assert second_recording.path.exists()
|
||||
assert first.source_path.read_bytes() == first_source_bytes
|
||||
assert second.source_path.exists()
|
||||
|
||||
|
||||
def test_pinned_cache_entry_survives_eviction_until_release(tmp_path: Path) -> None:
|
||||
first = _command(tmp_path / "first")
|
||||
second = replace(
|
||||
_command(tmp_path / "second"),
|
||||
session_id="20260716T205633Z_viewer_live",
|
||||
)
|
||||
|
||||
class SizedExporter(FakeExporter):
|
||||
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self.calls += 1
|
||||
destination.write_bytes(b"R" * (40 * 1024))
|
||||
return {
|
||||
"source_sha256": _sha256(source),
|
||||
"rrd_sha256": _sha256(destination),
|
||||
"rrd_bytes": destination.stat().st_size,
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
materializer = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=SizedExporter(),
|
||||
cache_max_bytes=50 * 1024,
|
||||
free_space_reserve_bytes=0,
|
||||
)
|
||||
first_recording, release = materializer.materialize_pinned(first)
|
||||
|
||||
with pytest.raises(RecordingMaterializationError, match="cache quota"):
|
||||
materializer.materialize(second)
|
||||
|
||||
assert first_recording.path.exists()
|
||||
|
||||
release()
|
||||
second_recording = materializer.materialize(second)
|
||||
|
||||
assert not first_recording.path.exists()
|
||||
assert second_recording.path.exists()
|
||||
|
||||
|
||||
def test_cache_free_space_reserve_refuses_export_without_touching_raw(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
source_bytes = command.source_path.read_bytes()
|
||||
materializer = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=FakeExporter(),
|
||||
cache_max_bytes=1024 * 1024,
|
||||
free_space_reserve_bytes=1024,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recording_module.shutil,
|
||||
"disk_usage",
|
||||
lambda _path: SimpleNamespace(free=1023),
|
||||
)
|
||||
|
||||
with pytest.raises(RecordingMaterializationError, match="free-space reserve"):
|
||||
materializer.materialize(command)
|
||||
|
||||
assert command.source_path.read_bytes() == source_bytes
|
||||
|
|
@ -0,0 +1,573 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC, iter_capture_frames
|
||||
from k1link.sessions import (
|
||||
LayoutConflictError,
|
||||
SessionIntegrityError,
|
||||
SessionNotFoundError,
|
||||
SessionStore,
|
||||
resolve_missioncore_evidence_dir,
|
||||
)
|
||||
|
||||
|
||||
def make_legacy_session(
|
||||
sessions_root: Path,
|
||||
session_id: str,
|
||||
*,
|
||||
created_at: str = "2026-07-16T20:56:32.699Z",
|
||||
) -> Path:
|
||||
session = sessions_root / session_id
|
||||
capture = session / "captures" / "mqtt_live"
|
||||
capture.mkdir(parents=True)
|
||||
frames = [
|
||||
("lixel/application/report/lio_pcl", b"point-frame"),
|
||||
("lixel/application/report/lio_pose", b"pose-frame"),
|
||||
]
|
||||
raw = bytearray(RAW_MAGIC)
|
||||
metadata: list[dict[str, object]] = []
|
||||
for sequence, (topic, payload) in enumerate(frames, start=1):
|
||||
topic_bytes = topic.encode()
|
||||
frame_offset = len(raw)
|
||||
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
|
||||
raw.extend(topic_bytes)
|
||||
raw.extend(payload)
|
||||
metadata.append(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"record_type": "message",
|
||||
"sequence": sequence,
|
||||
"received_at_utc": f"2026-07-16T20:56:{31 + sequence:02d}.699Z",
|
||||
"received_at_epoch_ns": 1_784_235_391_699_000_000 + sequence * 1_000_000_000,
|
||||
"received_monotonic_ns": 9_000_000_000 + sequence * 1_000_000_000,
|
||||
"topic": topic,
|
||||
"payload_bytes": len(payload),
|
||||
"raw_frame_offset": frame_offset,
|
||||
"raw_payload_offset": frame_offset + FRAME_HEADER.size + len(topic_bytes),
|
||||
"raw_frame_bytes": FRAME_HEADER.size + len(topic_bytes) + len(payload),
|
||||
}
|
||||
)
|
||||
raw_path = capture / "mqtt.raw.k1mqtt"
|
||||
raw_path.write_bytes(raw)
|
||||
raw_hash = hashlib.sha256(raw).hexdigest()
|
||||
metadata_path = capture / "mqtt.metadata.jsonl"
|
||||
metadata_path.write_text(
|
||||
"".join(json.dumps(record, separators=(",", ":")) + "\n" for record in metadata),
|
||||
encoding="utf-8",
|
||||
)
|
||||
metadata_hash = hashlib.sha256(metadata_path.read_bytes()).hexdigest()
|
||||
(capture / "mqtt.summary.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"created_at_utc": created_at,
|
||||
"completed_at_utc": "2026-07-16T21:20:43.018Z",
|
||||
"capture_elapsed_seconds": 1440.1,
|
||||
"stop_reason": "external_stop",
|
||||
"error": None,
|
||||
"message_count": 2,
|
||||
"raw_bytes": len(raw),
|
||||
"payload_bytes": sum(len(payload) for _, payload in frames),
|
||||
"topic_counts": {
|
||||
"lixel/application/report/lio_pcl": 1,
|
||||
"lixel/application/report/lio_pose": 1,
|
||||
},
|
||||
"artifact_hashes": {
|
||||
"raw_sha256": raw_hash,
|
||||
"metadata_jsonl_sha256": metadata_hash,
|
||||
},
|
||||
# This is deliberately sensitive and must not enter API DTOs.
|
||||
"target_ipv4": "192.168.99.77",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(session / "manifest.redacted.json").write_text(
|
||||
json.dumps({"started_at_utc": created_at, "target": "redacted"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
def test_evidence_root_is_private_and_configurable(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
assert resolve_missioncore_evidence_dir(repository) == (
|
||||
repository / ".runtime" / "mission-core" / "evidence" / "sessions"
|
||||
).resolve()
|
||||
|
||||
configured = tmp_path / "external-evidence"
|
||||
monkeypatch.setenv("MISSIONCORE_EVIDENCE_DIR", str(configured))
|
||||
assert resolve_missioncore_evidence_dir(repository) == configured.resolve()
|
||||
|
||||
|
||||
def test_catalog_reconciles_sessions_removed_from_one_evidence_root(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
assert store.import_legacy_viewer_live(sessions) == (session.name,)
|
||||
shutil.rmtree(session)
|
||||
|
||||
assert store.import_legacy_viewer_live(sessions) == ()
|
||||
assert store.list_recent().items == ()
|
||||
|
||||
|
||||
def make_recorded_camera_source(
|
||||
session: Path,
|
||||
source_id: str = "sensor.camera.left",
|
||||
*,
|
||||
complete: bool = True,
|
||||
) -> Path:
|
||||
epoch = session / "media" / source_id / "epoch-1"
|
||||
segments = epoch / "segments"
|
||||
segments.mkdir(parents=True)
|
||||
(epoch / "init.mp4").write_bytes(b"ftyp-mission-core")
|
||||
(segments / "1.m4s").write_bytes(b"moof-camera-frame")
|
||||
(epoch / "index.jsonl").write_text(
|
||||
json.dumps({"sequence": 1, "started_at_seconds": 0.0}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
if complete:
|
||||
(epoch / "summary.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "missioncore.camera-recording/v1",
|
||||
"source_id": source_id,
|
||||
"segment_count": 1,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return epoch
|
||||
|
||||
|
||||
def replace_summary_with_recovery_metadata(
|
||||
session: Path,
|
||||
*,
|
||||
corrupt_trailing_line: bool = False,
|
||||
) -> None:
|
||||
capture = session / "captures" / "mqtt_live"
|
||||
raw_path = capture / "mqtt.raw.k1mqtt"
|
||||
timestamps = (
|
||||
("2026-07-16T20:56:32.699Z", 1_784_235_392_699_000_000, 10_000_000_000),
|
||||
("2026-07-16T20:56:35.199Z", 1_784_235_395_199_000_000, 12_500_000_000),
|
||||
)
|
||||
records = []
|
||||
for frame, (timestamp, epoch_ns, monotonic_ns) in zip(
|
||||
iter_capture_frames(raw_path),
|
||||
timestamps,
|
||||
strict=True,
|
||||
):
|
||||
records.append(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"record_type": "message",
|
||||
"sequence": frame.sequence,
|
||||
"received_at_utc": timestamp,
|
||||
"received_at_epoch_ns": epoch_ns,
|
||||
"received_monotonic_ns": monotonic_ns,
|
||||
"topic": frame.topic,
|
||||
"payload_bytes": frame.raw_frame_bytes
|
||||
- FRAME_HEADER.size
|
||||
- len(frame.topic.encode("utf-8")),
|
||||
"raw_frame_offset": frame.raw_frame_offset,
|
||||
"raw_payload_offset": frame.raw_payload_offset,
|
||||
"raw_frame_bytes": frame.raw_frame_bytes,
|
||||
}
|
||||
)
|
||||
metadata = b"".join(
|
||||
(json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
for record in records
|
||||
)
|
||||
if corrupt_trailing_line:
|
||||
metadata += b'{"record_type":"message"'
|
||||
(capture / "mqtt.metadata.jsonl").write_bytes(metadata)
|
||||
(capture / "mqtt.summary.json").write_text("{corrupt", encoding="utf-8")
|
||||
|
||||
|
||||
def serialized(value: object) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def test_store_uses_private_wal_database_and_idempotently_imports_legacy_session(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
first = store.import_legacy_viewer_live(sessions)
|
||||
second = store.import_legacy_viewer_live(sessions)
|
||||
|
||||
assert first == second == ("20260716T205632Z_viewer_live",)
|
||||
page = store.list_recent()
|
||||
assert len(page.items) == 1
|
||||
summary = page.items[0]
|
||||
assert summary.status == "ready"
|
||||
assert summary.modalities == ("point-cloud", "trajectory")
|
||||
assert summary.replayable is True
|
||||
assert summary.source_count == 2
|
||||
assert "192.168.99.77" not in serialized(page.as_dict())
|
||||
assert str(repository) not in serialized(page.as_dict())
|
||||
|
||||
detail = store.get_session(summary.session_id)
|
||||
assert [source.source_id for source in detail.sources] == [
|
||||
"sensor.lidar.primary",
|
||||
"spatial.trajectory",
|
||||
]
|
||||
assert all(source.seekable for source in detail.sources)
|
||||
assert str(repository) not in serialized(detail.as_dict())
|
||||
command = store.prepare_replay(summary.session_id, speed=2.0, loop=True)
|
||||
assert command.source_path.name == "mqtt.raw.k1mqtt"
|
||||
assert command.speed == 2.0
|
||||
assert command.loop is True
|
||||
|
||||
with sqlite3.connect(store.database_path) as connection:
|
||||
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
|
||||
assert store.database_path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_recent_sessions_are_sorted_and_cursor_paginated(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
make_legacy_session(
|
||||
sessions,
|
||||
"20260716T191025Z_viewer_live",
|
||||
created_at="2026-07-16T19:10:25.352Z",
|
||||
)
|
||||
make_legacy_session(
|
||||
sessions,
|
||||
"20260716T205632Z_viewer_live",
|
||||
created_at="2026-07-16T20:56:32.699Z",
|
||||
)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
first = store.list_recent(limit=1)
|
||||
assert first.items[0].session_id == "20260716T205632Z_viewer_live"
|
||||
assert first.next_cursor == "20260716T205632Z_viewer_live"
|
||||
second = store.list_recent(limit=1, cursor=first.next_cursor)
|
||||
assert second.items[0].session_id == "20260716T191025Z_viewer_live"
|
||||
assert second.next_cursor is None
|
||||
|
||||
|
||||
def test_legacy_import_adds_video_only_for_validated_recording_tree(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
complete = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
incomplete = make_legacy_session(sessions, "20260716T205632Z_viewer_live_2")
|
||||
make_recorded_camera_source(complete)
|
||||
make_recorded_camera_source(incomplete, complete=False)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
complete_detail = store.get_session(complete.name)
|
||||
assert complete_detail.summary.modalities == ("point-cloud", "trajectory", "video")
|
||||
assert complete_detail.summary.source_count == 3
|
||||
camera = next(
|
||||
source for source in complete_detail.sources if source.source_id == "sensor.camera.left"
|
||||
)
|
||||
assert camera.modality == "video"
|
||||
assert camera.semantic_channel_id == "camera.video.recorded"
|
||||
assert camera.seekable is True
|
||||
video_artifact = next(
|
||||
artifact
|
||||
for artifact in complete_detail.artifacts
|
||||
if artifact.artifact_id == camera.artifact_id
|
||||
)
|
||||
assert video_artifact.kind == "recorded-video"
|
||||
assert video_artifact.integrity_status == "validated-structure"
|
||||
assert str(repository) not in serialized(complete_detail.as_dict())
|
||||
|
||||
incomplete_detail = store.get_session(incomplete.name)
|
||||
assert incomplete_detail.summary.modalities == ("point-cloud", "trajectory")
|
||||
assert all(source.modality != "video" for source in incomplete_detail.sources)
|
||||
|
||||
|
||||
def test_interrupted_capture_is_recovered_from_aligned_raw_and_metadata_prefix(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session, corrupt_trailing_line=True)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
assert detail.summary.status == "interrupted"
|
||||
assert detail.summary.replayable is True
|
||||
assert detail.summary.modalities == ("point-cloud", "trajectory")
|
||||
assert detail.summary.started_at_utc == "2026-07-16T20:56:32.699Z"
|
||||
assert detail.summary.completed_at_utc == "2026-07-16T20:56:35.199Z"
|
||||
assert detail.summary.duration_seconds == 2.5
|
||||
assert detail.artifacts[0].integrity_status == "validated-prefix"
|
||||
assert store.prepare_replay(session.name).source_path.name == "mqtt.raw.k1mqtt"
|
||||
|
||||
|
||||
def test_catalog_upsert_promotes_recovered_session_after_summary_is_completed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
summary_path = session / "captures" / "mqtt_live" / "mqtt.summary.json"
|
||||
metadata_path = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
|
||||
completed_summary = summary_path.read_text(encoding="utf-8")
|
||||
completed_metadata = metadata_path.read_text(encoding="utf-8")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
assert store.get_session(session.name).summary.status == "interrupted"
|
||||
|
||||
summary_path.write_text(completed_summary, encoding="utf-8")
|
||||
metadata_path.write_text(completed_metadata, encoding="utf-8")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
promoted = store.get_session(session.name).summary
|
||||
assert promoted.status == "ready"
|
||||
assert promoted.duration_seconds == 1440.1
|
||||
assert promoted.replayable is True
|
||||
|
||||
|
||||
def test_interrupted_capture_does_not_trust_metadata_outside_session(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
metadata = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
|
||||
outside = tmp_path / "outside.metadata.jsonl"
|
||||
outside.write_bytes(metadata.read_bytes())
|
||||
metadata.unlink()
|
||||
metadata.symlink_to(outside)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
assert detail.summary.status == "failed"
|
||||
assert detail.summary.replayable is False
|
||||
assert detail.summary.modalities == ()
|
||||
|
||||
|
||||
def test_interrupted_capture_rejects_newline_terminated_metadata_corruption(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
metadata = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
|
||||
with metadata.open("ab") as stream:
|
||||
stream.write(b"{corrupt\n")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
assert detail.summary.status == "failed"
|
||||
assert detail.summary.replayable is False
|
||||
|
||||
|
||||
def test_replay_resolution_rejects_artifact_symlink_escape(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
outside = tmp_path / "outside.k1mqtt"
|
||||
outside.write_bytes(raw.read_bytes())
|
||||
raw.unlink()
|
||||
raw.symlink_to(outside)
|
||||
|
||||
with pytest.raises(SessionIntegrityError, match="escapes"):
|
||||
store.prepare_replay(session.name)
|
||||
|
||||
|
||||
def test_completed_capture_is_not_replayable_when_declared_integrity_fails(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
corrupted = bytearray(raw.read_bytes())
|
||||
corrupted[-1] ^= 0x01
|
||||
raw.write_bytes(corrupted)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
assert detail.summary.status == "failed"
|
||||
assert detail.summary.replayable is False
|
||||
assert detail.summary.modalities == ()
|
||||
|
||||
|
||||
def test_completed_capture_is_not_replayable_when_declared_count_is_wrong(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
summary_path = session / "captures" / "mqtt_live" / "mqtt.summary.json"
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
summary["message_count"] = 3
|
||||
summary_path.write_text(json.dumps(summary), encoding="utf-8")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
assert store.get_session(session.name).summary.replayable is False
|
||||
|
||||
|
||||
def test_current_session_marker_keeps_active_capture_out_of_replay(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
(sessions / ".current_session").write_text(
|
||||
f"sessions/{session.name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
store.get_session(session.name)
|
||||
|
||||
(sessions / ".current_session").unlink()
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
assert store.get_session(session.name).summary.replayable is True
|
||||
|
||||
|
||||
def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_tail(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
committed_bytes = raw.stat().st_size
|
||||
with raw.open("ab") as stream:
|
||||
stream.write(FRAME_HEADER.pack(12, 100)[:7])
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
command = store.prepare_replay(session.name)
|
||||
assert detail.summary.status == "interrupted"
|
||||
assert detail.summary.replayable is True
|
||||
assert command.replay_byte_length == committed_bytes
|
||||
assert command.replay_byte_length < command.source_path.stat().st_size
|
||||
|
||||
|
||||
def test_interrupted_capture_rejects_non_frame_raw_tail(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
with raw.open("ab") as stream:
|
||||
stream.write(FRAME_HEADER.pack(0, 0))
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
assert store.get_session(session.name).summary.replayable is False
|
||||
|
||||
|
||||
def test_interrupted_capture_tolerates_bounded_group_commit_raw_tail(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
committed_bytes = raw.stat().st_size
|
||||
with raw.open("ab") as stream:
|
||||
for payload in (b"pending-one", b"pending-two"):
|
||||
topic = b"RealtimePath"
|
||||
stream.write(FRAME_HEADER.pack(len(topic), len(payload)))
|
||||
stream.write(topic)
|
||||
stream.write(payload)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
command = store.prepare_replay(session.name)
|
||||
assert store.get_session(session.name).summary.status == "interrupted"
|
||||
assert command.replay_byte_length == committed_bytes
|
||||
|
||||
|
||||
def test_failed_final_summary_falls_back_to_last_committed_prefix(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
capture = session / "captures" / "mqtt_live"
|
||||
metadata = capture / "mqtt.metadata.jsonl"
|
||||
first_record = metadata.read_text(encoding="utf-8").splitlines(keepends=True)[0]
|
||||
metadata.write_text(first_record, encoding="utf-8")
|
||||
summary_path = capture / "mqtt.summary.json"
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
summary["error"] = "synthetic metadata fsync failure"
|
||||
summary_path.write_text(json.dumps(summary), encoding="utf-8")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
assert detail.summary.status == "interrupted"
|
||||
assert detail.summary.replayable is True
|
||||
assert detail.summary.modalities == ("point-cloud",)
|
||||
|
||||
|
||||
def test_layout_save_is_atomic_and_revision_checked(tmp_path: Path) -> None:
|
||||
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
|
||||
|
||||
first = store.save_layout(
|
||||
"observation.spatial",
|
||||
schema_version=1,
|
||||
expected_revision=0,
|
||||
name="Операторская сцена",
|
||||
layout={"visible_source_ids": ["sensor.lidar.primary"], "windows": []},
|
||||
)
|
||||
assert first.revision == 1
|
||||
assert store.get_layout("observation.spatial") == first
|
||||
|
||||
with pytest.raises(LayoutConflictError, match="revision changed"):
|
||||
store.save_layout(
|
||||
"observation.spatial",
|
||||
schema_version=1,
|
||||
expected_revision=0,
|
||||
name="Устаревшая запись",
|
||||
layout={},
|
||||
)
|
||||
|
||||
second = store.save_layout(
|
||||
"observation.spatial",
|
||||
schema_version=1,
|
||||
expected_revision=1,
|
||||
name="Операторская сцена",
|
||||
layout={"visible_source_ids": [], "windows": []},
|
||||
)
|
||||
assert second.revision == 2
|
||||
assert store.get_layout("observation.spatial").layout["visible_source_ids"] == []
|
||||
|
|
@ -16,6 +16,14 @@ def _write_native(path: Path, topic: str, payload: bytes) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _append_native(path: Path, topic: str, payload: bytes) -> None:
|
||||
topic_raw = topic.encode()
|
||||
with path.open("ab") as stream:
|
||||
stream.write(FRAME_HEADER.pack(len(topic_raw), len(payload)))
|
||||
stream.write(topic_raw)
|
||||
stream.write(payload)
|
||||
|
||||
|
||||
def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
_write_native(capture, "lixel/application/report/lio_pose", b"pose")
|
||||
|
|
@ -39,6 +47,35 @@ def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
|
|||
assert message.received_monotonic_ns == 456
|
||||
|
||||
|
||||
def test_native_replay_stops_at_crash_truncated_metadata_tail(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
_write_native(capture, "RealtimePointcloud", b"first")
|
||||
_append_native(capture, "RealtimePointcloud", b"uncommitted-tail")
|
||||
first_record = {
|
||||
"record_type": "message",
|
||||
"sequence": 1,
|
||||
"received_at_epoch_ns": 123_000_000_000,
|
||||
"received_monotonic_ns": 456,
|
||||
}
|
||||
(tmp_path / "mqtt.metadata.jsonl").write_text(
|
||||
json.dumps(first_record) + "\n" + '{"record_type":"message"',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
messages = list(iter_replay_messages(capture))
|
||||
|
||||
assert [message.payload for message in messages] == [b"first"]
|
||||
|
||||
|
||||
def test_native_replay_rejects_newline_terminated_metadata_corruption(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
_write_native(capture, "RealtimePointcloud", b"frame")
|
||||
(tmp_path / "mqtt.metadata.jsonl").write_text("{corrupt\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ReplayFormatError, match="not valid JSON"):
|
||||
list(iter_replay_messages(capture))
|
||||
|
||||
|
||||
def test_legacy_tsv_replay_validates_and_decodes_payload(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "payloads.tsv"
|
||||
capture.write_bytes(b"1784124315.186225000\tRealtimePath\t4\t0001aaff\n")
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import k1link.web.xgrids_k1_camera as camera_module
|
||||
from k1link.web.xgrids_k1_camera import (
|
||||
CAMERA_MEDIA_TYPE,
|
||||
XgridsK1CameraGateway,
|
||||
|
|
@ -40,6 +43,63 @@ def _fake_ffmpeg(tmp_path: Path) -> Path:
|
|||
return executable
|
||||
|
||||
|
||||
def _burst_ffmpeg(tmp_path: Path) -> Path:
|
||||
executable = tmp_path / "burst-ffmpeg"
|
||||
executable.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import sys, time\n"
|
||||
"def box(kind, payload=b''):\n"
|
||||
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
"time.sleep(0.2)\n"
|
||||
"for index in range(10):\n"
|
||||
" sys.stdout.buffer.write(box(b'moof') + box(b'mdat', bytes([index])))\n"
|
||||
" sys.stdout.buffer.flush()\n"
|
||||
" time.sleep(0.01)\n"
|
||||
"time.sleep(10)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
executable.chmod(0o700)
|
||||
return executable
|
||||
|
||||
|
||||
def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path:
|
||||
executable = tmp_path / "buffered-tail-ffmpeg"
|
||||
executable.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import pathlib, sys, time\n"
|
||||
"def box(kind, payload=b''):\n"
|
||||
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
|
||||
f"for index in range({fragments}):\n"
|
||||
" sys.stdout.buffer.write(box(b'moof') + box(b'mdat', index.to_bytes(2, 'big')))\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
f"pathlib.Path({str(sentinel)!r}).write_text('ready')\n"
|
||||
"time.sleep(10)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
executable.chmod(0o700)
|
||||
return executable
|
||||
|
||||
|
||||
def _incomplete_tail_ffmpeg(tmp_path: Path, sentinel: Path) -> Path:
|
||||
executable = tmp_path / "incomplete-tail-ffmpeg"
|
||||
executable.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import pathlib, sys, time\n"
|
||||
"def box(kind, payload=b''):\n"
|
||||
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov') + box(b'moof'))\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
f"pathlib.Path({str(sentinel)!r}).write_text('ready')\n"
|
||||
"time.sleep(10)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
executable.chmod(0o700)
|
||||
return executable
|
||||
|
||||
|
||||
def _gateway(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
@ -48,6 +108,16 @@ def _gateway(
|
|||
return XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
|
||||
|
||||
def _wait_until(predicate: object, *, timeout: float = 3.0) -> None:
|
||||
assert callable(predicate)
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.01)
|
||||
raise AssertionError("condition was not reached before timeout")
|
||||
|
||||
|
||||
def test_camera_selection_is_exclusive_and_hides_device_transport(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
@ -90,9 +160,7 @@ def test_camera_ffmpeg_command_is_allowlisted_copy_remux() -> None:
|
|||
)
|
||||
|
||||
assert argv[0] == "/trusted/ffmpeg"
|
||||
assert argv[argv.index("-i") + 1] == (
|
||||
"rtsp://10.0.0.24:8554/live/chn_left_main"
|
||||
)
|
||||
assert argv[argv.index("-i") + 1] == ("rtsp://10.0.0.24:8554/live/chn_left_main")
|
||||
assert argv[argv.index("-c:v") + 1] == "copy"
|
||||
assert argv[argv.index("-allowed_media_types") + 1] == "video"
|
||||
assert argv[argv.index("-flush_packets") + 1] == "1"
|
||||
|
|
@ -145,6 +213,279 @@ def test_gateway_emits_init_and_complete_media_segments_without_transcoding(
|
|||
gateway.close()
|
||||
|
||||
|
||||
def test_acquisition_records_without_browser_and_source_switch_seals_epochs(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gateway = _gateway(tmp_path, monkeypatch)
|
||||
session = tmp_path / "sessions" / "camera-acquisition"
|
||||
try:
|
||||
left = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
gateway.start_recording(session)
|
||||
assert session.exists() is False
|
||||
session.mkdir(parents=True)
|
||||
gateway.start_recording(session)
|
||||
left_epoch = session / "media" / "sensor.camera.left" / f"epoch-{left['generation']}"
|
||||
|
||||
# No open_delivery/WebSocket exists: acquisition ownership alone starts
|
||||
# FFmpeg and commits the init segment before any preview consumer.
|
||||
_wait_until(
|
||||
lambda: (
|
||||
(left_epoch / "init.mp4").is_file()
|
||||
and len(list((left_epoch / "segments").glob("*.m4s"))) == 1
|
||||
)
|
||||
)
|
||||
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
|
||||
|
||||
right = gateway.select("sensor.camera.right", "192.168.1.20")
|
||||
right_epoch = session / "media" / "sensor.camera.right" / f"epoch-{right['generation']}"
|
||||
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
|
||||
_wait_until(lambda: len(list((right_epoch / "segments").glob("*.m4s"))) == 1)
|
||||
|
||||
lease = gateway.open_delivery(right["generation"])
|
||||
init_segment = lease.segments.get(timeout=1)
|
||||
assert init_segment is not None and init_segment[0] == "init"
|
||||
gateway.release_delivery(lease, client_closed=True)
|
||||
|
||||
# Browser disposal is not producer disposal while recording is active.
|
||||
assert lease.process.poll() is None
|
||||
assert gateway.snapshot()["recording"]["active"] is True
|
||||
assert gateway.snapshot()["phase"] == "streaming"
|
||||
|
||||
stopped = gateway.stop_recording(status="complete")
|
||||
assert stopped["recording"]["active"] is False
|
||||
assert stopped["recording"]["completed_epochs"] == 2
|
||||
|
||||
summaries = []
|
||||
for epoch in (left_epoch, right_epoch):
|
||||
init = (epoch / "init.mp4").read_bytes()
|
||||
entries = [
|
||||
json.loads(line)
|
||||
for line in (epoch / "index.jsonl").read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||
summaries.append(summary)
|
||||
assert [entry["kind"] for entry in entries] == ["media"]
|
||||
assert all(
|
||||
entry["length"] == len((epoch / entry["path"]).read_bytes())
|
||||
and hashlib.sha256((epoch / entry["path"]).read_bytes()).hexdigest()
|
||||
== entry["sha256"]
|
||||
for entry in entries
|
||||
)
|
||||
assert summary["status"] == "complete"
|
||||
assert summary["segment_count"] == 1
|
||||
assert summary["entry_count"] == 1
|
||||
assert summary["valid_bytes"] == len(init) + sum(
|
||||
entry["length"] for entry in entries
|
||||
)
|
||||
|
||||
assert summaries[0]["failure_code"] == "source-switch"
|
||||
assert summaries[1]["failure_code"] is None
|
||||
serialized = json.dumps(stopped)
|
||||
assert "192.168.1.20" not in serialized
|
||||
assert "rtsp://" not in serialized
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gateway = _gateway(tmp_path, monkeypatch)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
popen_called = False
|
||||
|
||||
class FailingArchive:
|
||||
def __init__(self, *_: object, **__: object) -> None:
|
||||
raise camera_module.CameraArchiveError("synthetic storage failure")
|
||||
|
||||
def forbidden_popen(*_: object, **__: object) -> object:
|
||||
nonlocal popen_called
|
||||
popen_called = True
|
||||
raise AssertionError("FFmpeg must not start without durable storage")
|
||||
|
||||
monkeypatch.setattr(camera_module, "CameraArchiveWriter", FailingArchive)
|
||||
monkeypatch.setattr(camera_module.subprocess, "Popen", forbidden_popen)
|
||||
try:
|
||||
gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
with pytest.raises(RuntimeError, match="хранилище"):
|
||||
gateway.start_recording(session)
|
||||
|
||||
state = gateway.snapshot()
|
||||
assert popen_called is False
|
||||
assert state["phase"] == "error"
|
||||
assert state["error"]["code"] == "camera-storage-failed"
|
||||
assert "192.168.1.20" not in json.dumps(state)
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_camera_storage_append_failure_fails_producer_and_epoch_loudly(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
original_append = camera_module.CameraArchiveWriter.append
|
||||
|
||||
def failing_append(
|
||||
writer: object,
|
||||
kind: object,
|
||||
payload: bytes,
|
||||
**timestamps: object,
|
||||
) -> dict[str, object]:
|
||||
if kind == "media":
|
||||
raise camera_module.CameraArchiveError("synthetic media commit failure")
|
||||
return original_append(writer, kind, payload, **timestamps) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(camera_module.CameraArchiveWriter, "append", failing_append)
|
||||
gateway = _gateway(tmp_path, monkeypatch)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
|
||||
_wait_until(lambda: gateway.snapshot()["phase"] == "error")
|
||||
state = gateway.snapshot()
|
||||
assert state["error"]["code"] == "camera-storage-failed"
|
||||
summary_path = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
/ "summary.json"
|
||||
)
|
||||
_wait_until(
|
||||
lambda: (
|
||||
summary_path.is_file()
|
||||
and json.loads(summary_path.read_text(encoding="utf-8"))["status"]
|
||||
== "failed"
|
||||
)
|
||||
)
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "failed"
|
||||
assert summary["failure_code"] == "camera-storage-failed"
|
||||
assert "192.168.1.20" not in json.dumps(state)
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_slow_browser_is_dropped_without_stopping_archive_producer(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISSIONCORE_FFMPEG_BINARY", str(_burst_ffmpeg(tmp_path)))
|
||||
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
lease = gateway.open_delivery(selected["generation"])
|
||||
|
||||
# Deliberately never drain the bounded queue.
|
||||
_wait_until(lambda: lease.failure_code == "consumer-too-slow")
|
||||
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
|
||||
assert lease.process.poll() is None
|
||||
assert gateway.snapshot()["recording"]["active"] is True
|
||||
|
||||
gateway.release_delivery(lease, client_closed=False)
|
||||
epoch = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
)
|
||||
_wait_until(
|
||||
lambda: len(list((epoch / "segments").glob("*.m4s"))) == 10,
|
||||
)
|
||||
gateway.stop_recording(status="complete")
|
||||
|
||||
summary_path = epoch / "summary.json"
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "complete"
|
||||
assert summary["segment_count"] == 10
|
||||
assert summary["media_segment_count"] == 10
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_clean_stop_drains_ffmpeg_stdout_before_sealing_archive(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fragment_count = 100
|
||||
sentinel = tmp_path / "ffmpeg-wrote-buffered-tail"
|
||||
monkeypatch.setenv(
|
||||
"MISSIONCORE_FFMPEG_BINARY",
|
||||
str(_buffered_tail_ffmpeg(tmp_path, sentinel, fragments=fragment_count)),
|
||||
)
|
||||
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
_wait_until(sentinel.is_file)
|
||||
|
||||
gateway.stop_recording(status="complete")
|
||||
|
||||
epoch = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
)
|
||||
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "complete"
|
||||
assert summary["segment_count"] == fragment_count
|
||||
assert len(list((epoch / "segments").glob("*.m4s"))) == fragment_count
|
||||
assert len((epoch / "index.jsonl").read_text(encoding="utf-8").splitlines()) == (
|
||||
fragment_count
|
||||
)
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_clean_stop_marks_incomplete_fragment_tail_interrupted(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sentinel = tmp_path / "ffmpeg-wrote-incomplete-tail"
|
||||
monkeypatch.setenv(
|
||||
"MISSIONCORE_FFMPEG_BINARY",
|
||||
str(_incomplete_tail_ffmpeg(tmp_path, sentinel)),
|
||||
)
|
||||
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
_wait_until(sentinel.is_file)
|
||||
|
||||
gateway.stop_recording(status="complete")
|
||||
|
||||
summary_path = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
/ "summary.json"
|
||||
)
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "interrupted"
|
||||
assert summary["failure_code"] == "incomplete-fmp4-fragment"
|
||||
assert summary["segment_count"] == 0
|
||||
state = gateway.snapshot()
|
||||
assert state["phase"] == "error"
|
||||
assert state["error"]["code"] == "incomplete-fmp4-fragment"
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
|
|||
Loading…
Reference in New Issue