◆ ForgeVis
Skip to content

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}

Протокол ​

  1. Подключение: Установка WebSocket соединения.
  2. Init Segment: Сервер немедленно отправляет fMP4 Initialization Segment (ftyp + moov) как бинарное сообщение.
  3. 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 работали:

  1. Узел должен знать камеру — из файла конфигурации, из базы или из кластера.
  2. Для камеры должен быть включен PTZ (ptz: true).
  3. В RTSP URL камеры должны быть логин/пароль (используются для ONVIF auth).

Типичные статусы ошибок:

  • 404 камера не найдена
  • 403 PTZ отключен для камеры
  • 422 неверные значения перемещения
  • 502 ошибка запроса к камере/ONVIF

Proprietary software.