Appearance
Management API
ForgeVis предоставляет RESTful API для управления камерами и WebSocket API для низколатентного стриминга.
Базовый URL
По умолчанию: http://localhost:9997
Эндпоинты
Проверка состояния (Health Check)
Доступны две пробы:
GET /api/health/live— liveness-проба. Возвращает 200 пока процесс способен отвечать на HTTP. Используйте как systemd-watchdog или контейнерную liveness-пробу.GET /api/health/ready— readiness-проба. Проверяет зависимости: storage-бэкенд, cluster store, writable recordings directory, не истёкшую лицензию. 200 если все проверки прошли, иначе 503. Без авторизации ответ содержит толькоstatus,versionиtimestamp; подробности по каждой проверке (checks) получает тот, кому разрешён Management API. Используйте как пробу для load balancer'а — учётные данные ему не нужны.
Пример ready-ответа на здоровой ноде:
json
{
"status": "ready",
"version": "0.2.8",
"timestamp": "2026-05-29T10:15:00+00:00",
"checks": {
"storage": { "configured": true, "reachable": true },
"cluster_store": { "configured": false },
"recordings_dir": { "path": "/var/lib/forgevis/recordings", "writable": true },
"license": { "valid": true }
}
}Метрики узла
GET /api/metrics
Лёгкое состояние узла и имя, которым он себя называет.
json
{
"online": true,
"node_name": "recorder-north",
"cpu_load": 12,
"mem_used_gb": 6,
"mem_total_gb": 32,
"storage_free_gb": 812,
"storage_used_gb": 1100,
"timestamp": "2026-08-24T09:15:00+00:00"
}node_name берётся из верхнеуровневой настройки nodeName — под этим именем управляющая платформа и должна заводить узел, спрашивая его самого, а не принимая из формы. Камеры раздаются рекордеру по имени, сверяясь с cameras.node_name в базе, и расхождение в один символ нигде не даёт ошибки: узел читает пустой список камер и продолжает отвечать на проверки состояния.
TIP
Оставьте nodeName пустым — узел возьмёт системный hostname. Встроенного значения по умолчанию нет: общее означало бы, что два узла докладываются одним именем и берут одни и те же камеры. При настроенной базе узел, которому неоткуда взять имя, не стартует.
Для сбора Prometheus есть отдельный слушатель — см. мониторинг.
Список сегментов
GET /api/archive/{camera_id}/segments?from=<epoch>&to=<epoch>
Архив камеры за интервал — и закрытые сегменты, и тот, что пишется прямо сейчас, — вместе со списком дней, за которые у камеры вообще есть записи. Один запрос отвечает на оба вопроса таймлинии: чем заполнен выбранный день и какие дни вообще стоит предлагать. Отдельно спрашивать про текущую запись не требуется.
from и to — секунды эпохи. Если не указана ни одна, интервал — текущие сутки; если только from — сутки от него, если только to — сутки до него. Интервал — не больше суток (25 часов, чтобы поместились местные сутки с переводом часов): таймлиния рисует один день, а дни, которые стоит предлагать, приходят в days того же ответа.
Сегмент попадает в выдачу, если пересекается с интервалом, — в том числе начавшийся до from и заходящий внутрь. Иначе первый сегмент суток пропадал бы из выдачи за эти сутки.
days не зависит от интервала: это все дни архива камеры, YYYY-MM-DD. С tz_offset_minutes — на сколько минут зритель впереди UTC, 180 для UTC+3 — дни считаются по часам зрителя, без него — по часам узла: запись позднего вечера узла для зрителя на несколько часов восточнее — уже следующее утро. Календарь строится из того же ответа, без обхода архива.
json
{
"archive": [
{
"timestamp": "2026-08-21T14:54:43+00:00",
"sequence": 0,
"filename": "2026/08/21/stream-camera_001_2026-08-21_14-54-43.mp4",
"size": 921600000,
"mtime": 1755780883,
"is_open": false
},
{
"timestamp": "2026-08-21T15:09:43+00:00",
"sequence": 0,
"filename": "2026/08/21/stream-camera_001_2026-08-21_15-09-43.mp4",
"size": 86114304,
"mtime": 1755781783,
"is_open": true
}
],
"days": ["2026-08-19", "2026-08-20", "2026-08-21"]
}is_open истинно ровно у одного сегмента — того, который ещё пишется. Сказано прямо, а не оставлено на догадку: дойдя до его конца, зритель дошёл не до конца записи, а до конца того, что существовало на момент вопроса, и различать это, сравнивая длительность с часами, — значит ошибаться, как только список немного устарел. У остальных is_open ложно, а size окончателен.
Камеры, о которой узел ничего не знает, достаточно, чтобы получить пустой ответ: 200 с пустыми archive и days, а не ошибка.
Ошибки возвращаются как ошибки, а не как пустой архив:
| Код | Когда |
|---|---|
400 | граница не число, from позже to, интервал шире суток или tz_offset_minutes вне −840…840 |
503 | не читается база с индексом архива |
500 | не читается архив на диске |
Источник ответа зависит от того, как узел развёрнут:
| Развёртывание | Закрытые сегменты | Текущий |
|---|---|---|
| Отдельный узел без базы | обход каталога записей | добавляется, если камера пишется |
| Отдельный узел с базой | таблица recordings | добавляется, если камера пишется |
| Кластер, камера пишется этим узлом | recordings — записи всех узлов | добавляется |
| Кластер, камера пишется другим узлом | 307 на тот узел | там же |
Кластер
Спрашивать можно любой узел кластера. Если камеру ведёт другой, узел отвечает 307 Temporary Redirect с адресом нужного, сохраняя from и to. Клиент повторяет запрос сам, со своими учётными данными — узлы не хранят учётных данных друг друга.
Текущая запись
GET /api/archive/{camera_id}/current
Файл, который узел пишет для камеры прямо сейчас, и момент, с которого он начат.
Сегмент попадает в базу данных при закрытии, поэтому самой свежей части архива — вплоть до целой длины сегмента — нет ни в одном списке, построенном по этой базе. Этот эндпоинт её и добавляет: ответить может только узел, который держит файл.
json
{
"camera_id": "camera_001",
"filename": "2026/08/21/stream-camera_001_2026-08-21_15-09-43.mp4",
"start": "2026-08-21T15:09:43+00:00",
"size": 86114304
}size — сколько было записано на момент вопроса; пока ответ идёт, файл уже больше. Длительности нет: у файла ещё нет конца.
Отвечает 404, когда сообщать нечего — камера не записывается, поток замолчал и последний файл закрыт, либо в record.format указан mp4. Прогрессивная запись хранит заголовок в конце файла и пишет его при закрытии, поэтому незакрытая не воспроизводится, и показать её означало бы вывести на шкалу времени отрезок, который не открывается.
В кластере спрашивать можно любой узел: тот, который камеру не пишет, ответит 307 с адресом того, который пишет, — как и список сегментов выше.
Воспроизводится как любая другая запись — через сервер воспроизведения: он отдаёт и файл, который ещё пишется.
Архивные задания (Export & Time-Lapse)
Экспорт архива (склейка сегментов через stream-copy) и генерация time-lapse (ускоренное переэнкодирование) выполняются как асинхронные фоновые задания с единым API статуса и скачивания.
Постановка экспорта
POST /api/archive/{camera_id}/export?from=<epoch>&to=<epoch>
Склеивает сегменты, перекрывающие заданное окно, в один MP4 через ffmpeg -c copy (точность обрезки до миллисекунд, без перекодирования).
Постановка time-lapse
POST /api/archive/{camera_id}/timelapse?from=<epoch>&to=<epoch>&speed=<N>
Делает ускоренный MP4 из того же диапазона. speed — целое [2, 120]; длительность выхода = длительность входа / speed. Выход — x264 (veryfast, CRF 23), downscale до ≤1280px по ширине, 30 fps, аудио вырезается.
Ответ на submit
Оба submit'а возвращают 202 Accepted:
json
{
"job_id": "1a2b3c...",
"status_url": "/api/archive/jobs/1a2b3c...",
"download_url": "/api/archive/jobs/1a2b3c.../download"
}Общие лимиты: максимальное окно 4 часа (иначе 413 Payload Too Large); требуется ffmpeg на ноде (иначе 503 Service Unavailable).
Параллелизм: 4 export-задания и 2 timelapse-задания на ноду; лишние ждут в очереди со статусом pending.
Статус
GET /api/archive/jobs/{job_id}
json
{
"job_id": "1a2b3c...",
"camera_id": "camera_001",
"from": 1780054469,
"to": 1780054589,
"created_at": 1780100000,
"completed_at": 1780100012,
"kind": "export",
"state": "done",
"size_bytes": 18374212
}Для timelapse kind — timelapse, включается speed. Возможные state: pending, running, done (с size_bytes), failed (с reason).
Скачивание
GET /api/archive/jobs/{job_id}/download
Стримит готовый MP4. По умолчанию inline; ?download=1 форсит attachment. Возвращает 202 если задание ещё выполняется, 410 Gone если результат истёк (хранится 1 час после завершения).
Пример
bash
JOB=$(curl -sX POST 'http://127.0.0.1:9997/api/archive/camera_001/export?from=1780054469&to=1780054589' | jq -r .job_id)
while [ "$(curl -s "http://127.0.0.1:9997/api/archive/jobs/$JOB" | jq -r .state)" != "done" ]; do
sleep 1
done
curl "http://127.0.0.1:9997/api/archive/jobs/$JOB/download?download=1" -o clip.mp4Известное ограничение: этот эндпойнт работает только с локальной файловой системой ноды. Если камера в окне жила на нескольких нодах, вызов одной ноды покажет только её часть архива. Для бесшовного экспорта поверх миграций используйте сервис управления (platform/backend, скоро) — см. Multi-node archive.
Делегированная склейка (/assemble)
POST /api/archive/{camera_id}/assemble
Сервисный эндпойнт, который вызывает сервис управления, когда выбирает эту ноду в роли «assembler'а» для мульти-нодного экспорта. Принимает список уже-готовых под-экспортов с других нод, скачивает их параллельно и склеивает в один MP4. Если в теле есть kind: "timelapse" со speed, после склейки делается ещё один ffmpeg-pass с setpts=PTS/N.
Body:
json
{
"kind": "export",
"parts": [
{"peer_addr": "10.0.0.1:9997", "peer_job_id": "abc...", "ordinal": 0},
{"peer_addr": "10.0.0.2:9997", "peer_job_id": "def...", "ordinal": 1}
]
}Ответ — стандартный 202 Accepted с job_id. Дальше всё как у обычного export-задания: /api/archive/jobs/{id} и /download. Эндпойнт рассчитан на вызовы из доверенной сети — конечному клиенту не нужен.
Управление камерами
Запуск камеры
POST /cameras/start
Запускает камеру по ID.
Тело запроса:
json
{
"id": "camera_001"
}Ответ:
json
{
"status": "ok",
"started": 1
}Остановка камеры
POST /cameras/stop
Останавливает камеру по ID.
Тело запроса:
json
{
"id": "camera_001"
}Ответ:
json
{
"status": "ok",
"stopped": 1
}Кадр с камеры
GET /api/cameras/{id}/snapshot
Кадр с камеры в том виде, в каком его сделала камера, — обычно JPEG. Узел забирает его по адресу snapshot камеры, а для камер из базы — по snapuri. Если камера просит логин по Digest или Basic, узел отвечает учётными данными из этого адреса или из source.
Один кадр отвечает на все запросы к камере в течение 15 секунд, и ответ сообщает это в Cache-Control. Запросы, пришедшие, пока узел забирает кадр, ждут этот же кадр, а не идут к камере сами, поэтому стена камер спрашивает каждую камеру один раз.
Статусы ошибок:
404— камеры нет или у неё не задан адрес кадра502— камера ответила ошибкой или не картинкой504— камера не ответила за 10 секунд
Низколатентный стриминг (MSE)
ForgeVis поддерживает стриминг с низкой задержкой (<1с) напрямую в браузеры, используя Media Source Extensions (MSE) через WebSocket.
WebSocket Endpoint
URL: ws://localhost:9997/websocket/{camera_id}
Протокол
- Подключение: Установка WebSocket соединения.
- Init Segment: Сервер немедленно отправляет fMP4 Initialization Segment (ftyp + moov) как бинарное сообщение.
- Media Fragments: Сервер отправляет fMP4 Media Fragments (moof + mdat) как бинарные сообщения в реальном времени.
Реализация клиента (JavaScript)
javascript
const video = document.querySelector('video');
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', () => {
// Используйте правильную строку кодека (например, avc1.4d401f для H.264 Main Profile)
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.4d401f"');
const ws = new WebSocket('ws://localhost:9997/websocket/camera_001');
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => {
if (!sourceBuffer.updating) {
sourceBuffer.appendBuffer(event.data);
} else {
// Обработка переполнения буфера или очереди
}
};
});PTZ API управления камерой
ForgeVis предоставляет ONVIF PTZ endpoints в Management API.
Перемещение камеры
POST /api/ptz/move
Тело запроса:
json
{
"camera_id": "camera_001",
"pan": 0.4,
"tilt": -0.2,
"zoom": 0.0,
"speed": 0.5
}Правила:
pan,tilt,zoomдолжны быть в диапазоне[-1.0, 1.0]speedопционален и ограничивается диапазоном[0.0, 1.0]
Успешный ответ:
json
{
"status": "ok",
"message": "ptz move completed"
}Остановка движения камеры
POST /api/ptz/stop
Тело запроса:
json
{
"camera_id": "camera_001"
}Успешный ответ:
json
{
"status": "ok",
"message": "ptz stop completed"
}Требования для PTZ
Чтобы PTZ endpoints работали:
- Узел должен знать камеру — из файла конфигурации, из базы или из кластера.
- Для камеры должен быть включен PTZ (
ptz: true). - В RTSP URL камеры должны быть логин/пароль (используются для ONVIF auth).
Типичные статусы ошибок:
404камера не найдена403PTZ отключен для камеры422неверные значения перемещения502ошибка запроса к камере/ONVIF