Compare commits
20 Commits
f8e27b9e85
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e57999b6b8 | |||
| 265c5c9503 | |||
| 7bd8184159 | |||
| ce7aa6cb8d | |||
| e54d55371c | |||
| e76360dbc4 | |||
| b68d00604f | |||
| 3730c65a1e | |||
| a8ce3f1ccb | |||
| d8e69c6392 | |||
| 88fa73f922 | |||
| 75271436f7 | |||
| 362871a264 | |||
| 24d398526e | |||
| e8392dd5ff | |||
| 858fe61b56 | |||
| 9d2a0b2bd7 | |||
| 6e0273f4b4 | |||
| beb8e1baa0 | |||
| f1c79eabde |
+7
-1
@@ -2,11 +2,17 @@ cmake_minimum_required(VERSION 3.20)
|
||||
project(cuframes-composer
|
||||
VERSION 0.1.0
|
||||
DESCRIPTION "Multi-source video grid composer на CUDA + NVENC + RTSP"
|
||||
LANGUAGES C CUDA
|
||||
LANGUAGES C CXX CUDA
|
||||
)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
# Phase 11b — C++17 для ООП-модели Cell/Layout/Decoration. Low-level
|
||||
# модули (source, nvenc, frigate_mqtt, health, writer, audio) остаются
|
||||
# на C; их API объявлен `extern "C"` чтобы линковаться с C++ кодом.
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
# CUDA архитектуры. Покрываем production-сценарии:
|
||||
|
||||
@@ -1,55 +1,105 @@
|
||||
# cuframes-composer
|
||||
|
||||
Стандалонный композитор-демон для multi-source видео grid через CUDA + NVENC + RTSP.
|
||||
CUDA-композитор multi-source видео в один H.264 RTSP-поток с
|
||||
авто-раскладкой по motion и runtime-управлением через ONVIF/ZMQ.
|
||||
|
||||
Заменяет монолитный ffmpeg-конвейер (`ffmpeg + vf_cuda_grid` фильтр) для случаев, когда нужно:
|
||||
Заменяет монолитный ffmpeg-конвейер (`ffmpeg + vf_cuda_grid` фильтр) для
|
||||
случаев, когда нужно:
|
||||
|
||||
- Поток продолжает работать при потере любого числа источников (graceful degradation)
|
||||
- Композитор сам управляет частотой кадров и обработкой ошибок без зависимости от семантики ffmpeg-демухера
|
||||
- Минимум перемещений данных: zero-copy CUDA от источника `cuframes` напрямую в NVENC
|
||||
- Поток продолжает работать при потере любого числа источников
|
||||
(graceful degradation, blank cells вместо crash'а)
|
||||
- Композитор сам управляет частотой кадров без зависимости от ffmpeg-демухера
|
||||
- Минимум перемещений данных: zero-copy CUDA от cuframes-publisher до NVENC
|
||||
- Auto-layout по движению (Frigate-driven), без оператора
|
||||
- Управление с TV через ONVIF PTZ
|
||||
|
||||
## Что внутри
|
||||
|
||||
- **CUDA-композитор** (C++ ООП-ядро + extern "C" ABI, Phase 11b)
|
||||
- **Auto-layout** с asymmetric hysteresis + best-fit selection
|
||||
- **PTZ-override** через ONVIF (с auto-возвратом в motion-mode)
|
||||
- **MQTT-driven text overlays** (температура, статусы, etc.)
|
||||
- **Detection box overlay** от Frigate, следует за камерой при смене layout
|
||||
- **ZMQ control plane** для runtime-управления (set_layout, set_text, ...)
|
||||
- **NVENC** через `dlopen` (LGPL-совместимая интеграция)
|
||||
|
||||
## Документация
|
||||
|
||||
| | Русский | English |
|
||||
|---|---|---|
|
||||
| Для пользователя | [docs/ru/user.md](docs/ru/user.md) | [docs/en/user.md](docs/en/user.md) |
|
||||
| Для разработчика | [docs/ru/developer.md](docs/ru/developer.md) | [docs/en/developer.md](docs/en/developer.md) |
|
||||
| Operations / deploy | [docs/ru/operations.md](docs/ru/operations.md) | [docs/en/operations.md](docs/en/operations.md) |
|
||||
|
||||
## Статус
|
||||
|
||||
**Phase 1 — MVP.** В разработке. Не для боевой эксплуатации.
|
||||
**Phase 11b — production.** Развёрнут на R9-88.23 в составе CCTV-стека.
|
||||
|
||||
См. [дизайн-документ](https://git.goldix.org/gx/cuframes/raw/branch/main/docs/DESIGN-composer-daemon.md) для архитектурных решений и поэтапного плана.
|
||||
См. [STATE.md](../../localhost-infra/STATE.md) для текущего состояния
|
||||
infra и git-history `main` для эволюции по фазам.
|
||||
|
||||
## Зависимости
|
||||
|
||||
- [cuframes](https://git.goldix.org/gx/cuframes) — библиотека zero-copy передачи кадров. Подключена как git submodule.
|
||||
- [nv-codec-headers](https://github.com/FFmpeg/nv-codec-headers) — MIT-licensed заголовки NVENC API. Подключена как git submodule. Сама библиотека `libnvidia-encode.so` грузится через `dlopen` при старте (это даёт LGPL-совместимость — см. дизайн-документ часть 1.6).
|
||||
- CUDA Toolkit 12.x+ (для cuda runtime и компиляции)
|
||||
- NVIDIA драйвер 525+ (для NVENC и `cuMemCreate` POSIX FD)
|
||||
- Linux 64-bit (POSIX shm, SCM_RIGHTS)
|
||||
- [cuframes](https://git.goldix.org/gx/cuframes) — zero-copy frame
|
||||
delivery от RTSP-publisher'а к composer'у. Подключена как git submodule.
|
||||
- [nv-codec-headers](https://github.com/FFmpeg/nv-codec-headers) —
|
||||
MIT-licensed заголовки NVENC API (submodule). `libnvidia-encode.so`
|
||||
грузится через `dlopen` в runtime для LGPL-совместимости.
|
||||
- CUDA Toolkit 12.x+ (cudart, nvcc, driver API)
|
||||
- NVIDIA driver 525+ (NVENC, cuMemCreate POSIX FD)
|
||||
- FreeType, libpng, libzmq, libjson-c, libmosquitto, libavformat/avcodec/avutil
|
||||
- Linux 64-bit (POSIX shm, SCM_RIGHTS, named pipes)
|
||||
|
||||
Дополнительно по фазам:
|
||||
- Phase 3: `libfreetype` (текст), `lodepng` через submodule (PNG-декодирование)
|
||||
- Phase 4: `libzmq` (управление)
|
||||
|
||||
## Сборка
|
||||
## Quick start (host build)
|
||||
|
||||
```bash
|
||||
git clone --recursive git@git.goldix.org:gx/cuframes-composer.git
|
||||
cd cuframes-composer
|
||||
cmake -B build -G Ninja
|
||||
ninja -C build
|
||||
git submodule update --init --recursive
|
||||
cmake -B build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
## Поэтапный план
|
||||
Артефакты:
|
||||
- `build/src/libcuframes_composer.so` — shared library
|
||||
- `build/examples/grid_record` — main CLI entry (C)
|
||||
- `build/examples/grid_record_cpp` — C++ smoke test
|
||||
|
||||
| фаза | срок | результат |
|
||||
|---|---|---|
|
||||
| 1 | 1 неделя | один источник → NVENC → файл .h264 (доказательство zero-copy) |
|
||||
| 2 | 2 недели | четыре источника + композиция через `libcugrid` |
|
||||
| 3 | 2 недели | оверлеи + RTSP push к mediamtx + AAC passthrough из `/live-audio` |
|
||||
| 4 | 1 неделя | паритет ZMQ-управления с фильтром `vf_cuda_grid` |
|
||||
| 5 | 1 неделя | боевое развёртывание + MQTT health + watchdog |
|
||||
| 6 | 2 недели | тесты + бенчмарки + документация |
|
||||
Запуск (1 камера, motion-mode, JSON-templates):
|
||||
```bash
|
||||
build/examples/grid_record \
|
||||
--out=/tmp/grid.h264 --fps=25 --bitrate=6000 \
|
||||
--width=1920 --height=1080 \
|
||||
--source=cam-parking,frigate=parking_overview,priority=100 \
|
||||
--motion-mode --motion-ttl=45000 \
|
||||
--templates=docker/templates.json \
|
||||
--mqtt-overlays=docker/mqtt_overlays.json
|
||||
```
|
||||
|
||||
Итого ~9 недель для одного разработчика.
|
||||
Production deploy и jammy-build — см.
|
||||
[docs/ru/operations.md](docs/ru/operations.md).
|
||||
|
||||
## Архитектура одной картинкой
|
||||
|
||||
```
|
||||
Frigate ──MQTT events──→ frigate_mqtt subscriber
|
||||
│
|
||||
↓ motion_pulse
|
||||
cuframes-pub-* ──VMM──→ Composer (C++ Cell/Layout/Decoration)
|
||||
│
|
||||
↓ best-fit + hysteresis
|
||||
Layout::apply()
|
||||
│
|
||||
↓ NV12 zero-copy
|
||||
NVENC ──→ H.264 pipe
|
||||
↓
|
||||
cfc-grid-ffmpeg
|
||||
↓ RTSP push
|
||||
mediamtx ──→ TV/VLC/HLS/WebRTC
|
||||
↑
|
||||
cctv-onvif (PTZ → ZMQ set_layout)
|
||||
```
|
||||
|
||||
Подробности — [docs/ru/developer.md](docs/ru/developer.md) §1.
|
||||
|
||||
## Лицензия
|
||||
|
||||
LGPL-2.1-or-later. См. файл [LICENSE](LICENSE).
|
||||
|
||||
NVENC SDK headers (`third_party/nv-codec-headers`) — MIT license, совместима с LGPL.
|
||||
LGPL-2.1+
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"version": 1,
|
||||
"grid_cols": 8,
|
||||
"grid_rows": 8,
|
||||
"_doc": "MQTT-driven text overlays. Каждый блок = одна MQTT-подписка + persistent text overlay в фиксированной позиции на output frame'е. Не привязан к layout cells. anchor: right-bottom/right-top/left-bottom/left-top/center. format: printf-style для extracted значения (для double — \"%+.1f°C\"). json_field пустой → raw payload как string.",
|
||||
|
||||
"overlays": [
|
||||
{
|
||||
"id": "temp_outside",
|
||||
"topic": "zigbee2mqtt/Температура на улице",
|
||||
"json_field": "temperature",
|
||||
"format": "%+.1f°C",
|
||||
"anchor": "right-bottom",
|
||||
"margin_x": 32,
|
||||
"margin_y": 24,
|
||||
"pixel_size": 32,
|
||||
"color": [255, 255, 255],
|
||||
"alpha": 230,
|
||||
"font_path": "/fonts/DejaVuSans-Bold.ttf"
|
||||
}
|
||||
]
|
||||
}
|
||||
+49
-9
@@ -2,30 +2,31 @@
|
||||
"version": 1,
|
||||
"grid_cols": 8,
|
||||
"grid_rows": 8,
|
||||
"_doc": "Layout-templates для cfc-grid auto-layout. Координаты в микроячейках 8×8 (output 1920×1080 → каждая микроячейка 240×135 px, 16:9). Квадраты N×N микроячеек тоже 16:9. role=camera — заполняется из активных камер по priority. role=widget — placeholder.",
|
||||
"_doc": "Phase 11b — набор layouts на 8×8 микро-сетке. Свободные camera-cells при нехватке motion-камер заполняются остальными drawable из pool (cfc::Composer::maybe_relayout). Widget cells показывают placeholder (тёмно-серый + название); реальные виджеты — Phase 12+.",
|
||||
|
||||
"templates": [
|
||||
{
|
||||
"name": "tpl_1",
|
||||
"_desc": "1 камера во весь экран.",
|
||||
"_desc": "Одна камера во весь экран.",
|
||||
"cells": [
|
||||
{"col": 0, "row": 0, "cs": 8, "rs": 8, "role": "camera", "order": 0}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tpl_3",
|
||||
"_desc": "Главная 1440×810 слева + 2 превью 480×270 справа стопкой, остаток — виджеты.",
|
||||
"_desc": "Главная 1440×810 + 3 превью 480×270 + temp_chart 480×270 (низ-право) + ha_chat снизу.",
|
||||
"cells": [
|
||||
{"col": 0, "row": 0, "cs": 6, "rs": 6, "role": "camera", "order": 0},
|
||||
{"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1},
|
||||
{"col": 6, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 2},
|
||||
{"col": 6, "row": 4, "cs": 2, "rs": 4, "role": "widget", "widget": "temp_chart"},
|
||||
{"col": 6, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 3},
|
||||
{"col": 6, "row": 6, "cs": 2, "rs": 2, "role": "widget", "widget": "temp_chart"},
|
||||
{"col": 0, "row": 6, "cs": 6, "rs": 2, "role": "widget", "widget": "ha_chat"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tpl_4",
|
||||
"_desc": "Quad 2×2: 4 камеры 960×540. order=0 — top-left (главная).",
|
||||
"_desc": "Quad 2×2 — 4 камеры 960×540 (16:9). order=0 — top-left.",
|
||||
"cells": [
|
||||
{"col": 0, "row": 0, "cs": 4, "rs": 4, "role": "camera", "order": 0},
|
||||
{"col": 4, "row": 0, "cs": 4, "rs": 4, "role": "camera", "order": 1},
|
||||
@@ -35,7 +36,7 @@
|
||||
},
|
||||
{
|
||||
"name": "tpl_5",
|
||||
"_desc": "1 главная + 4 превью справа стопкой, нижняя полоса — виджет.",
|
||||
"_desc": "Главная + 4 превью справа стопкой, снизу — widget.",
|
||||
"cells": [
|
||||
{"col": 0, "row": 0, "cs": 6, "rs": 6, "role": "camera", "order": 0},
|
||||
{"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1},
|
||||
@@ -47,7 +48,7 @@
|
||||
},
|
||||
{
|
||||
"name": "tpl_6",
|
||||
"_desc": "1 главная + 3 правые + 2 нижние, остаток — виджет.",
|
||||
"_desc": "Главная + 3 правых + 2 нижних, остаток нижней строки — widget.",
|
||||
"cells": [
|
||||
{"col": 0, "row": 0, "cs": 6, "rs": 6, "role": "camera", "order": 0},
|
||||
{"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1},
|
||||
@@ -60,7 +61,7 @@
|
||||
},
|
||||
{
|
||||
"name": "tpl_7",
|
||||
"_desc": "1 главная + 3 правые + 3 нижние, угол — виджет.",
|
||||
"_desc": "Главная + 3 правых + 3 нижних, угол — widget.",
|
||||
"cells": [
|
||||
{"col": 0, "row": 0, "cs": 6, "rs": 6, "role": "camera", "order": 0},
|
||||
{"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1},
|
||||
@@ -74,7 +75,7 @@
|
||||
},
|
||||
{
|
||||
"name": "tpl_8",
|
||||
"_desc": "1+3+4 — главная + 3 правые + полная нижняя строка.",
|
||||
"_desc": "1+3+4 — главная + 3 правых + 4 в нижней строке (без widget).",
|
||||
"cells": [
|
||||
{"col": 0, "row": 0, "cs": 6, "rs": 6, "role": "camera", "order": 0},
|
||||
{"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1},
|
||||
@@ -85,6 +86,45 @@
|
||||
{"col": 4, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 6},
|
||||
{"col": 6, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 7}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tpl_9",
|
||||
"_desc": "3×3 (cells по 2×2 микроячейки в области 6×6, остаток — widget).",
|
||||
"cells": [
|
||||
{"col": 0, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 0},
|
||||
{"col": 2, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1},
|
||||
{"col": 4, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 2},
|
||||
{"col": 0, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 3},
|
||||
{"col": 2, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 4},
|
||||
{"col": 4, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 5},
|
||||
{"col": 0, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 6},
|
||||
{"col": 2, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 7},
|
||||
{"col": 4, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 8},
|
||||
{"col": 6, "row": 0, "cs": 2, "rs": 6, "role": "widget", "widget": "temp_chart"},
|
||||
{"col": 0, "row": 6, "cs": 8, "rs": 2, "role": "widget", "widget": "ha_chat"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tpl_16",
|
||||
"_desc": "4×4 — 16 камер 480×270 (16:9), полностью покрывает 8×8.",
|
||||
"cells": [
|
||||
{"col": 0, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 0},
|
||||
{"col": 2, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1},
|
||||
{"col": 4, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 2},
|
||||
{"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 3},
|
||||
{"col": 0, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 4},
|
||||
{"col": 2, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 5},
|
||||
{"col": 4, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 6},
|
||||
{"col": 6, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 7},
|
||||
{"col": 0, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 8},
|
||||
{"col": 2, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 9},
|
||||
{"col": 4, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 10},
|
||||
{"col": 6, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 11},
|
||||
{"col": 0, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 12},
|
||||
{"col": 2, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 13},
|
||||
{"col": 4, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 14},
|
||||
{"col": 6, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 15}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
# cfc-grid — developer guide
|
||||
|
||||
> Audience: developer who edits composer C++ code, adds new
|
||||
> Cell/Decoration/overlay types, or changes auto-layout logic.
|
||||
>
|
||||
> If you're a user — see [user.md](user.md). For deploy/troubleshooting
|
||||
> see [operations.md](operations.md).
|
||||
|
||||
## 1. Architecture (40000-foot view)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ cfc::Composer (C++) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||
│ │SourcePool│ │ Layout │ │OutputSurface │ │
|
||||
│ │ (pool of │ │ vector< │ │ CudaBuffer │ │
|
||||
│ │ cfc_ │ │ unique_ │ │ (VMM NV12) │ │
|
||||
│ │source_t*)│ │ ptr<Cell>│ │ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────────┘ │
|
||||
│ ↓ ↓ ↑ │
|
||||
│ motion_pulse apply() NVENC │
|
||||
└──────────────────────────────────────────────┘
|
||||
↑ ↓
|
||||
extern "C" ABI shim H.264 pipe
|
||||
(composer_c_api.cpp, ↓
|
||||
layouts_c_api.cpp, ffmpeg-relay
|
||||
mqtt_overlay_c_api.cpp) ↓
|
||||
↑ mediamtx
|
||||
┌────────┴────────┐ ↓
|
||||
│ │ ↓
|
||||
grid_record.c control.c clients
|
||||
(C, CLI parsing) (ZMQ verbs)
|
||||
↑ ↑
|
||||
│ │
|
||||
frigate_mqtt.c health.c, audio.c, writer.c, source.c
|
||||
(Frigate events) (C-only modules)
|
||||
```
|
||||
|
||||
**Principles:**
|
||||
|
||||
- **C++17 host-side**, CUDA kernels remain on C
|
||||
- **Zero-copy:** output VMM buffer is passed as `NV12Ref` to all
|
||||
cells/decorations (read-write reference, not copy)
|
||||
- **Coexistence:** C modules (`source.c`, `nvenc.c`, `frigate_mqtt.c`,
|
||||
`health.c`, `writer.c`, `audio.c`, `overlay.c`) are kept, accessed via
|
||||
`extern "C"`. Only `composer.c` and `layouts.c` were removed (replaced
|
||||
by C++ + ABI shim).
|
||||
- **`grid_record.c`** (CLI entry point) remains in C, uses public
|
||||
`cfc_composer_*` API unchanged
|
||||
|
||||
## 2. Source tree
|
||||
|
||||
```
|
||||
include/cuframes_composer/
|
||||
├── *.h # Public C API (extern "C")
|
||||
│ ├── composer.h # cfc_composer_* ABI
|
||||
│ ├── layouts.h # cfc_layout_* ABI
|
||||
│ ├── overlay.h # cfc_overlay_* (text/png/border/detbox)
|
||||
│ ├── source.h, nvenc.h, frigate_mqtt.h, ...
|
||||
│ └── ...
|
||||
└── cpp/ # C++ headers — public classes
|
||||
├── cuda_raii.hpp # CudaBuffer, CudaStream (RAII)
|
||||
├── types.hpp # Rect, NV12Ref
|
||||
├── cell.hpp # abstract Cell + draw()
|
||||
├── camera_cell.hpp # CameraCell : Cell
|
||||
├── widget_cell.hpp # WidgetCell : Cell
|
||||
├── blank_cell.hpp # BlankCell : Cell
|
||||
├── decoration.hpp # abstract Decoration
|
||||
├── border_decoration.hpp # BorderDecoration
|
||||
├── label_decoration.hpp # LabelDecoration (FreeType internal)
|
||||
├── template.hpp # LayoutTemplate, CellTemplate, kGridCols/Rows
|
||||
├── template_loader.hpp # JSON parser, current_templates()
|
||||
├── source_pool.hpp # SourcePool, PoolEntry
|
||||
├── layout.hpp # Layout
|
||||
├── composer.hpp # Composer (main class)
|
||||
└── mqtt_overlay.hpp # MqttOverlayItem + Manager
|
||||
|
||||
src/
|
||||
├── *.c # C modules (source/nvenc/health/...)
|
||||
├── overlay.c # All cfc_overlay_t types
|
||||
├── frigate_mqtt.c # Subscribe + motion_pulse + zone-filter
|
||||
├── cugrid/cugrid.cu # CUDA kernels (resize, fill, blit)
|
||||
└── cpp/ # C++ implementations
|
||||
├── camera_cell.cpp, widget_cell.cpp, blank_cell.cpp
|
||||
├── border_decoration.cpp, label_decoration.cpp
|
||||
├── source_pool.cpp
|
||||
├── template_loader.cpp
|
||||
├── layout.cpp
|
||||
├── composer.cpp
|
||||
├── composer_c_api.cpp # extern "C" shim for composer
|
||||
├── layouts_c_api.cpp # extern "C" shim for layouts
|
||||
├── mqtt_overlay.cpp
|
||||
└── mqtt_overlay_c_api.cpp
|
||||
|
||||
examples/
|
||||
├── simple_record.c # 1-source → H.264 file
|
||||
├── grid_record.c # Main CLI entry point (C)
|
||||
└── grid_record_cpp.cpp # C++ smoke (uses cfc::Composer directly)
|
||||
```
|
||||
|
||||
## 3. Single frame lifecycle
|
||||
|
||||
```cpp
|
||||
// In compose loop (grid_record.c, via cfc_composer_compose):
|
||||
NV12Ref ref = composer.compose_frame();
|
||||
// 1. maybe_relayout(): motion-mode? best-fit template; apply Layout
|
||||
// 2. compose_clear(): fill output buffer BT.709 black
|
||||
// 3. Layout::render(): for each cell: draw_content() + decorations[]
|
||||
// 4. for each overlay: sync detbox geom + cfc_overlay_draw()
|
||||
// cudaStreamSynchronize(0);
|
||||
// nvenc.encode(ref.y_ptr, ref.pitch_y, ref.uv_ptr, ref.pitch_uv, ...);
|
||||
```
|
||||
|
||||
All operations execute on CUDA default stream. Zero-copy:
|
||||
`ref.y_ptr` / `ref.uv_ptr` are the same `CUdeviceptr`s inside
|
||||
`composer.output_` (RAII `CudaBuffer`).
|
||||
|
||||
## 4. Cell — hierarchy and extension
|
||||
|
||||
### 4.1 Abstraction
|
||||
|
||||
```cpp
|
||||
class Cell {
|
||||
public:
|
||||
explicit Cell(const Rect& geom);
|
||||
virtual ~Cell() = default;
|
||||
Cell(const Cell&) = delete;
|
||||
|
||||
const Rect& geometry() const noexcept;
|
||||
void set_geometry(const Rect& r) noexcept;
|
||||
void add_decoration(std::unique_ptr<Decoration>);
|
||||
|
||||
void draw(CUstream stream, NV12Ref& dst); // public — calls draw_content() + decorations
|
||||
|
||||
protected:
|
||||
virtual void draw_content(CUstream stream, NV12Ref& dst) = 0;
|
||||
Rect geom_;
|
||||
std::vector<std::unique_ptr<Decoration>> decorations_;
|
||||
};
|
||||
```
|
||||
|
||||
`Cell::draw()` is final (non-virtual — NVI pattern):
|
||||
```cpp
|
||||
void Cell::draw(CUstream stream, NV12Ref& dst) {
|
||||
if (geom_.empty()) return;
|
||||
draw_content(stream, dst); // subclass renders content
|
||||
for (auto& d : decorations_) {
|
||||
d->draw(stream, dst, geom_); // overlay decorations on top
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Existing subclasses
|
||||
|
||||
| Class | draw_content implementation |
|
||||
|---|---|
|
||||
| `CameraCell` | `cfc_source_get_latest()` + `cfc_cugrid_resize_nv12` |
|
||||
| `WidgetCell` | `cfc_cugrid_fill_nv12` dark grey Y=40 (placeholder) |
|
||||
| `BlankCell` | `cfc_cugrid_fill_nv12` BT.709 black Y=16 |
|
||||
|
||||
### 4.3 How to add a new Cell type
|
||||
|
||||
Example: `GraphCell` — draws scrolling chart from MQTT subscription.
|
||||
|
||||
1. Create `include/cuframes_composer/cpp/graph_cell.hpp`:
|
||||
|
||||
```cpp
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_GRAPH_CELL_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_GRAPH_CELL_HPP
|
||||
#include "cell.hpp"
|
||||
#include <deque>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
class GraphCell : public Cell {
|
||||
public:
|
||||
GraphCell(const Rect& geom, std::size_t max_points = 60)
|
||||
: Cell(geom), max_points_(max_points) {}
|
||||
|
||||
void push_value(double v) {
|
||||
if (values_.size() >= max_points_) values_.pop_front();
|
||||
values_.push_back(v);
|
||||
}
|
||||
|
||||
protected:
|
||||
void draw_content(CUstream stream, NV12Ref& dst) override;
|
||||
|
||||
private:
|
||||
std::deque<double> values_;
|
||||
std::size_t max_points_;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
#endif
|
||||
```
|
||||
|
||||
2. Implementation `src/cpp/graph_cell.cpp`:
|
||||
|
||||
```cpp
|
||||
#include "../../include/cuframes_composer/cpp/graph_cell.hpp"
|
||||
#include "../../include/cuframes_composer/cugrid.h"
|
||||
|
||||
namespace cfc {
|
||||
|
||||
void GraphCell::draw_content(CUstream stream, NV12Ref& dst) {
|
||||
if (geom_.empty() || values_.empty()) return;
|
||||
|
||||
// 1. BG fill
|
||||
cfc_cugrid_fill_nv12(stream, dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
geom_.x, geom_.y, geom_.w, geom_.h,
|
||||
/*Y*/ 30, 128, 128, 255);
|
||||
|
||||
// 2. Compute min/max
|
||||
double mn = *std::min_element(values_.begin(), values_.end());
|
||||
double mx = *std::max_element(values_.begin(), values_.end());
|
||||
if (mx == mn) mx = mn + 1.0;
|
||||
|
||||
// 3. Render as thin filled rects (lazy, no bitmap kernel)
|
||||
int n = static_cast<int>(values_.size());
|
||||
int dx = geom_.w / n;
|
||||
for (int i = 0; i < n; i++) {
|
||||
double norm = (values_[i] - mn) / (mx - mn);
|
||||
int y_px = geom_.y + geom_.h - 2 - (int)(norm * (geom_.h - 4));
|
||||
cfc_cugrid_fill_nv12(stream, dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
geom_.x + i * dx, y_px, dx & ~1, 2,
|
||||
/*Y*/ 235, 128, 64, 255); // limited-range bright
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
```
|
||||
|
||||
3. Add to `src/CMakeLists.txt`:
|
||||
```cmake
|
||||
set(COMPOSER_SOURCES_CPP
|
||||
...
|
||||
cpp/graph_cell.cpp
|
||||
)
|
||||
```
|
||||
|
||||
4. Use in `Layout::apply()`:
|
||||
|
||||
```cpp
|
||||
// In layout.cpp, widget cells handling:
|
||||
if (wt->widget == "graph_temp") {
|
||||
cells_.push_back(std::make_unique<GraphCell>(r, /*max_points=*/120));
|
||||
} else {
|
||||
cells_.push_back(std::make_unique<WidgetCell>(r, wt->widget));
|
||||
}
|
||||
```
|
||||
|
||||
5. Wire MQTT → `GraphCell::push_value()` — either via MqttOverlayManager
|
||||
extension, or new API `cfc::Composer::register_graph_feed()`.
|
||||
|
||||
## 5. Decoration — composition in Cell
|
||||
|
||||
### 5.1 Abstraction
|
||||
|
||||
```cpp
|
||||
class Decoration {
|
||||
public:
|
||||
virtual ~Decoration() = default;
|
||||
virtual void draw(CUstream stream, NV12Ref& dst, const Rect& parent_rect) = 0;
|
||||
};
|
||||
```
|
||||
|
||||
Decoration knows only parent cell's pixel-rect — positions **relative
|
||||
to it**. Has no access to Layout/Composer.
|
||||
|
||||
### 5.2 Existing subclasses
|
||||
|
||||
- **`LabelDecoration`** — FreeType atlas + `cfc_cugrid_blit_rgba_nv12`.
|
||||
Persistent VRAM (rebuild on `set_text`). Supports background fill
|
||||
(via `cfc_overlay_text_config_t.bg_*` parameters).
|
||||
- **`BorderDecoration`** — 4 calls of `cfc_cugrid_fill_nv12` (top, bottom,
|
||||
left, right rects by `thickness`).
|
||||
|
||||
### 5.3 How to add a new Decoration type
|
||||
|
||||
Example: `BadgeDecoration` — corner icon (e.g. red "recording" dot).
|
||||
|
||||
1. Header `badge_decoration.hpp`:
|
||||
|
||||
```cpp
|
||||
class BadgeDecoration : public Decoration {
|
||||
public:
|
||||
struct Style {
|
||||
int corner = 0; // 0=top-left, 1=top-right, 2=bottom-right, 3=bottom-left
|
||||
int size = 12;
|
||||
int margin = 8;
|
||||
int color_y = 80, color_u = 90, color_v = 240; // red-ish
|
||||
int alpha = 240;
|
||||
bool visible = true;
|
||||
};
|
||||
explicit BadgeDecoration(const Style& s) : style_(s) {}
|
||||
void set_visible(bool v) noexcept { style_.visible = v; }
|
||||
void draw(CUstream stream, NV12Ref& dst, const Rect& p) override;
|
||||
private:
|
||||
Style style_;
|
||||
};
|
||||
```
|
||||
|
||||
2. Implementation:
|
||||
|
||||
```cpp
|
||||
void BadgeDecoration::draw(CUstream s, NV12Ref& dst, const Rect& p) {
|
||||
if (!style_.visible || style_.alpha <= 0) return;
|
||||
int x, y;
|
||||
switch (style_.corner) {
|
||||
case 0: x = p.x + style_.margin; y = p.y + style_.margin; break;
|
||||
case 1: x = p.x + p.w - style_.size - style_.margin; y = p.y + style_.margin; break;
|
||||
case 2: x = p.x + p.w - style_.size - style_.margin; y = p.y + p.h - style_.size - style_.margin; break;
|
||||
case 3: x = p.x + style_.margin; y = p.y + p.h - style_.size - style_.margin; break;
|
||||
}
|
||||
x &= ~1; y &= ~1;
|
||||
cfc_cugrid_fill_nv12(s, dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
x, y, style_.size, style_.size,
|
||||
style_.color_y, style_.color_u, style_.color_v, style_.alpha);
|
||||
}
|
||||
```
|
||||
|
||||
3. Wire in `Layout::apply()`:
|
||||
|
||||
```cpp
|
||||
if (cc->source()->state() == CFC_SOURCE_STALE) {
|
||||
BadgeDecoration::Style bs;
|
||||
bs.corner = 1; bs.color_y = 180; bs.color_v = 100; // yellow
|
||||
cell->add_decoration(std::make_unique<BadgeDecoration>(bs));
|
||||
}
|
||||
```
|
||||
|
||||
## 6. SourcePool and motion state
|
||||
|
||||
```cpp
|
||||
struct PoolEntry {
|
||||
std::string cuframes_key;
|
||||
std::string frigate_camera;
|
||||
int priority;
|
||||
cfc_source_t* source;
|
||||
std::atomic<int64_t> last_motion_ms;
|
||||
std::vector<std::string> required_zones;
|
||||
|
||||
cfc_source_state_t state() const; // calls cfc_source_get_latest
|
||||
bool drawable() const; // ACTIVE || STALE
|
||||
};
|
||||
|
||||
class SourcePool {
|
||||
public:
|
||||
int add(key, frigate_camera, priority, zones, SubscribeOpts);
|
||||
PoolEntry* by_key(const std::string&);
|
||||
PoolEntry* by_frigate_camera(const std::string&);
|
||||
template<typename F> void for_each(F&&);
|
||||
void motion_pulse(const std::string& frigate_camera,
|
||||
const std::vector<std::string>& current_zones);
|
||||
};
|
||||
```
|
||||
|
||||
`motion_pulse` is called from `frigate_mqtt.c` for each event. If
|
||||
`required_zones` is non-empty — match by intersection with
|
||||
`event.current_zones`, otherwise accept all.
|
||||
|
||||
## 7. Auto-layout algorithms
|
||||
|
||||
See `cfc::Composer::maybe_relayout()` (src/cpp/composer.cpp).
|
||||
|
||||
### 7.1 Best-fit selection
|
||||
|
||||
```cpp
|
||||
const LayoutTemplate* Composer::pick_best_fit(int need) const {
|
||||
const auto& reg = current_templates();
|
||||
const LayoutTemplate* best = nullptr;
|
||||
int best_waste = -1, best_prio = -1;
|
||||
for (auto& t : reg) {
|
||||
int n = t.nb_camera_cells();
|
||||
if (n < need) continue;
|
||||
int waste = n - need;
|
||||
if (!best || waste < best_waste ||
|
||||
(waste == best_waste && t.priority > best_prio)) {
|
||||
best = &t; best_waste = waste; best_prio = t.priority;
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
// overflow → largest
|
||||
for (auto& t : reg) {
|
||||
if (!best || t.nb_camera_cells() > best->nb_camera_cells()) best = &t;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Collect active
|
||||
|
||||
```cpp
|
||||
std::vector<PoolEntry*> Composer::collect_active() const {
|
||||
std::vector<PoolEntry*> active;
|
||||
int64_t now = now_ms_mono();
|
||||
pool_.for_each([&](PoolEntry& e) {
|
||||
if (!e.drawable()) return; // DEAD/CONNECTING — skip
|
||||
int64_t last = e.last_motion_ms.load();
|
||||
if (last == 0) return; // never had motion
|
||||
if (now - last > cfg_.motion_ttl_ms) return; // TTL expired
|
||||
active.push_back(&e);
|
||||
});
|
||||
// idle fallback: top-priority drawable
|
||||
if (active.empty()) {
|
||||
PoolEntry* best = nullptr;
|
||||
pool_.for_each([&](PoolEntry& e) {
|
||||
if (!e.drawable()) return;
|
||||
if (!best || e.priority > best->priority) best = &e;
|
||||
});
|
||||
if (best) active.push_back(best);
|
||||
}
|
||||
std::sort(active.begin(), active.end(),
|
||||
[](PoolEntry* a, PoolEntry* b) { return a->priority > b->priority; });
|
||||
return active;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 Asymmetric hysteresis
|
||||
|
||||
Signature `template_name + "|" + sorted_keys` is remembered. If new
|
||||
set ⊇ committed (growth) → commit immediately. Otherwise wait
|
||||
`shrink_hysteresis_ms` (default 3000) → commit.
|
||||
|
||||
```cpp
|
||||
bool is_grow = std::includes(nkeys.begin(), nkeys.end(),
|
||||
ckeys.begin(), ckeys.end());
|
||||
if (is_grow && nkeys.size() == ckeys.size()) is_grow = false;
|
||||
if (is_grow) { /* commit */ } else {
|
||||
if (sig != pending_signature_) {
|
||||
pending_signature_ = sig;
|
||||
pending_first_seen_ms_ = now;
|
||||
}
|
||||
if (now - pending_first_seen_ms_ < cfg_.shrink_hysteresis_ms) return;
|
||||
/* commit */
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 Fill free cells
|
||||
|
||||
After picking template and capping by `nb_camera_cells`:
|
||||
|
||||
```cpp
|
||||
if (active.size() < cap) {
|
||||
std::vector<PoolEntry*> extras;
|
||||
pool_.for_each([&](PoolEntry& e) {
|
||||
if (!e.drawable()) return;
|
||||
if (e ∈ already_active) return;
|
||||
extras.push_back(&e);
|
||||
});
|
||||
std::sort(extras.begin(), extras.end(), priority_desc);
|
||||
while (active.size() < cap && !extras.empty()) {
|
||||
active.push_back(extras.front());
|
||||
extras.erase(extras.begin());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 Manual PTZ override
|
||||
|
||||
`Composer::set_layout(name)` in motion-mode:
|
||||
```cpp
|
||||
manual_override_until_ms_ = now + manual_override_duration_ms_; // default 60s
|
||||
```
|
||||
|
||||
`maybe_relayout()` skips work while `now < manual_override_until_ms_`.
|
||||
After expiry — `committed_signature_.clear()` → forced relayout.
|
||||
|
||||
## 8. extern "C" ABI shim
|
||||
|
||||
`composer_c_api.cpp` — thin wrapper:
|
||||
|
||||
```cpp
|
||||
extern "C" int cfc_composer_create(const cfc_composer_config_t* cfg,
|
||||
cfc_composer_t** out)
|
||||
{
|
||||
cfc::ComposerConfig cpp_cfg = {/* ... */};
|
||||
auto* comp = new (std::nothrow) cfc::Composer(cpp_cfg);
|
||||
if (!comp->ok()) { delete comp; return -1; }
|
||||
// manual_cells from cfg->cells → set_manual_cells()
|
||||
*out = reinterpret_cast<cfc_composer_t*>(comp);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
`cfc_composer_t` is opaque in C, `reinterpret_cast` to/from `cfc::Composer*`
|
||||
in shim.
|
||||
|
||||
`layouts_c_api.cpp` is analogous for `cfc_layout_*`. Holds static cache
|
||||
`vector<cfc_layout_t>` resynced with `cfc::current_templates()` on reload.
|
||||
|
||||
## 9. Build
|
||||
|
||||
### 9.1 CMake
|
||||
|
||||
```cmake
|
||||
project(cuframes-composer LANGUAGES C CXX CUDA)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
```
|
||||
|
||||
`COMPOSER_SOURCES_CPP` in `src/CMakeLists.txt` lists all .cpp files.
|
||||
|
||||
### 9.2 Host build (for CI / dev machine Ubuntu 24.04)
|
||||
|
||||
```bash
|
||||
cd /home/claude/projects/cuframes-composer
|
||||
mkdir -p build && cd build
|
||||
cmake -DCMAKE_BUILD_TYPE=Release ..
|
||||
make -j$(nproc)
|
||||
```
|
||||
|
||||
### 9.3 Production build (Ubuntu 22.04 jammy)
|
||||
|
||||
See [operations.md](operations.md). Briefly:
|
||||
```bash
|
||||
docker run --rm --gpus all \
|
||||
-v "$PWD":/src -w /src/build-jammy \
|
||||
cuframes-composer-builder:cached \
|
||||
bash -c 'cmake .. && make -j$(nproc)'
|
||||
```
|
||||
|
||||
## 10. Performance
|
||||
|
||||
### 10.1 Zero-copy guarantees
|
||||
|
||||
- One `CudaBuffer output_` per `Composer`, passed as `NV12Ref` to all
|
||||
cells/decorations
|
||||
- Cell does not create VRAM allocations per frame (only `Decoration::draw`
|
||||
may do FreeType rebuild on `set_text` — that's offline)
|
||||
- Layout::apply() recreates `vector<unique_ptr<Cell>>` only on template
|
||||
change; typically every N seconds
|
||||
|
||||
### 10.2 Virtual call overhead
|
||||
|
||||
`Cell::draw()` → `draw_content()` virtual call: 1 indirect call per cell
|
||||
per frame. At 25 fps × 16 cells = **400 calls/sec** — irrelevant.
|
||||
|
||||
### 10.3 Hot path
|
||||
|
||||
CUDA kernels (`cugrid.cu`):
|
||||
- `fill_nv12` — 1 kernel launch per rect
|
||||
- `resize_nv12` — bilinear via 2 kernels (Y plane + UV plane)
|
||||
- `blit_rgba_nv12` — 1 kernel, RGBA → NV12 + alpha-blend
|
||||
|
||||
**All Cell operations convert to N×fill_nv12 + 1×resize_nv12 +
|
||||
M×blit_rgba_nv12** — batching not yet needed (GPU is not sticky on
|
||||
current load).
|
||||
|
||||
## 11. Where to edit common tasks
|
||||
|
||||
| I want to… | File |
|
||||
|---|---|
|
||||
| Change hysteresis | `composer.hpp` → `ComposerConfig::shrink_hysteresis_ms` |
|
||||
| Change cell border color | `layout.cpp` → `border_style.color_y/u/v` |
|
||||
| Change label font | `mqtt_overlay.cpp` → `LabelDecoration` style + `LabelStyle::font_path` |
|
||||
| Add ZMQ verb | `control.c::dispatch()` + new `cmd_*` function |
|
||||
| Change manual override duration | `composer.hpp` → `manual_override_duration_ms_` |
|
||||
| Add new MQTT-overlay anchor | `mqtt_overlay.cpp::reposition_overlay()` switch |
|
||||
| Support color emoji | `overlay.c::text_rebuild_atlas()` — handle `FT_LOAD_COLOR` + bitmap BGRA → blit as RGBA |
|
||||
|
||||
## 12. Testing
|
||||
|
||||
### 12.1 Unit (not implemented)
|
||||
|
||||
Planned: catch2 tests for `pick_best_fit`, `collect_active`, hysteresis.
|
||||
CUDA dependency — mock via `cfc_source_get_latest` shim.
|
||||
|
||||
### 12.2 Integration smoke (`examples/grid_record_cpp.cpp`)
|
||||
|
||||
Minimal C++ smoke: init Composer, compose loop, dump NV12 → file. Does
|
||||
not use NVENC (tests composition only).
|
||||
|
||||
```bash
|
||||
build/examples/grid_record_cpp \
|
||||
--out=/tmp/dump.nv12 --frames=10 --width=1920 --height=1080 \
|
||||
--templates=docker/templates.json \
|
||||
--source=cam-parking,frigate=parking_overview,priority=100 \
|
||||
--motion-mode
|
||||
```
|
||||
|
||||
### 12.3 Production smoke
|
||||
|
||||
`docker logs cfc-grid | grep -E "loaded|template|motion|grow|shrink"` —
|
||||
live composer telemetry.
|
||||
|
||||
## 13. Known pitfalls
|
||||
|
||||
- **`cfc_overlay_t` is not RAII** — managed via `cfc_composer_add_overlay`
|
||||
/ `cfc_overlay_destroy` (Composer.cpp::~Composer destroys all).
|
||||
- **`pthread_mutex_t` in SourcePool** vs `std::mutex` — chose `std::mutex`
|
||||
for C++ layer, `pthread_mutex_t` for C layer (PoolEntry::last_motion_ms
|
||||
uses `std::atomic`).
|
||||
- **Compose loop is NOT blocking** on CUDA ops — `cudaStreamSynchronize`
|
||||
called by caller (grid_record.c) before NVENC.
|
||||
- **Frigate event may arrive BEFORE Layout::apply** — `motion_pulse`
|
||||
updates `last_motion_ms`, but cell for that camera may not exist yet.
|
||||
On next frame `maybe_relayout` recalculates.
|
||||
- **`dynamic_cast<CameraCell*>` in `Layout::find_camera_cell_rect`** —
|
||||
uses RTTI. Enabled `-frtti` by default in g++/nvcc.
|
||||
|
||||
## 14. Phase task change workflow
|
||||
|
||||
1. Branch `feature/<phase>-<feature>` off `main`
|
||||
2. Implementation, host build PASS, jammy build PASS (see operations.md)
|
||||
3. Bake image `gx/cuframes-composer:<phase>-stepN`
|
||||
4. Deploy to dev target → smoke verify via VLC/logs
|
||||
5. Commit + push branch
|
||||
6. (If needed) — merge to `main` via `--no-ff` PR-style
|
||||
|
||||
For multi-commit phase: one WIP merge commit on main describing key
|
||||
changes.
|
||||
@@ -0,0 +1,402 @@
|
||||
# cfc-grid — operations / deploy / troubleshooting
|
||||
|
||||
> Audience: who builds, deploys, monitors cfc-grid in production.
|
||||
>
|
||||
> If you're a user — see [user.md](user.md). For developer documentation
|
||||
> — see [developer.md](developer.md).
|
||||
|
||||
## 1. Production setup (R9-88.23)
|
||||
|
||||
### 1.1 Stack
|
||||
|
||||
```
|
||||
docker compose -f docker-compose.yml \
|
||||
-f cuda-grid/docker-compose.override.yml \
|
||||
-f cuframes-composer/docker-compose.override.yml \
|
||||
-f onvif/docker-compose.override.yml \
|
||||
up -d
|
||||
```
|
||||
|
||||
Files — in `localhost-infra/hosts/R9-88.23/docker/cctv/`.
|
||||
|
||||
| Service | Image | Purpose |
|
||||
|---|---|---|
|
||||
| `cuframes-ipc-anchor` | `gx/cuframes:0.4` | Shared VMM IPC anchor for cuframes |
|
||||
| `cuframes-pub-*` (parking/back_yard/front_yard/gate_lpr) | `gx/cuframes:0.4` | RTSP → cuframes per-camera publishers |
|
||||
| `cuda-grid-mediamtx` | `bluenviron/mediamtx` | RTSP/HLS/WebRTC gateway |
|
||||
| `cctv-mosquitto` | `eclipse-mosquitto` | MQTT broker (+bridge to 192.168.88.4) |
|
||||
| **`cfc-grid`** | `gx/cuframes-composer:0.11b-step1` | Composer (main service) |
|
||||
| `cfc-grid-ffmpeg` | `ffmpeg-vf-cuda-grid:phase4b-final` | H.264 pipe → RTSP push |
|
||||
| `cfc-grid-watchdog` | `gx/cuda-grid-watchdog:0.4` | Restart cfc-grid on stuck inboundBytes |
|
||||
| `cctv-onvif` | `gx/cctv-onvif:0.6` | ONVIF discovery + PTZ → ZMQ |
|
||||
| `cctv-frigate` | `ghcr.io/blakeblackshear/frigate` | Object detection → MQTT events |
|
||||
|
||||
### 1.2 Frame flow
|
||||
|
||||
```
|
||||
cuframes-pub-X ──VMM──┐
|
||||
cuframes-pub-Y ──VMM──┼──→ cfc-grid (composer)
|
||||
cuframes-pub-Z ──VMM──┘ │
|
||||
│ H.264 NVENC
|
||||
↓ named pipe /tmp/cfc-pipe-dir/grid.h264
|
||||
cfc-grid-ffmpeg (re-mux)
|
||||
│ RTSP push
|
||||
↓
|
||||
cuda-grid-mediamtx
|
||||
rtsp://*/cfc-grid (TCP/UDP)
|
||||
http://*:8888/cfc-grid (HLS)
|
||||
http://*:8889/cfc-grid (WebRTC)
|
||||
```
|
||||
|
||||
### 1.3 Networks
|
||||
|
||||
- Internal docker network: `cctv`
|
||||
- External ports on R9-88.23:
|
||||
- `554/tcp` — RTSP (mediamtx)
|
||||
- `8888/tcp` — HLS (mediamtx)
|
||||
- `8889/tcp` — WebRTC (mediamtx)
|
||||
- `5599/tcp` — ZMQ composer control plane
|
||||
- `8085/tcp` — ONVIF SOAP (cctv-onvif)
|
||||
- `3702/udp` — WS-Discovery multicast (cctv-onvif)
|
||||
|
||||
## 2. Build
|
||||
|
||||
### 2.1 Local host build (Ubuntu 24.04, dev machine)
|
||||
|
||||
```bash
|
||||
cd /home/claude/projects/cuframes-composer
|
||||
cmake -B build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
Artifacts in `build/src/libcuframes_composer.so` and `build/examples/grid_record`.
|
||||
|
||||
**IMPORTANT:** host binary (Ubuntu 24.04, glibc 2.39, libavformat60) is
|
||||
**incompatible** with runtime container (Ubuntu 22.04 jammy, glibc 2.35,
|
||||
libavformat58). See memory `incremental-ffmpeg-rebuild`.
|
||||
|
||||
### 2.2 Jammy build (for production image)
|
||||
|
||||
Uses cached builder container `cuframes-composer-builder:cached`
|
||||
(Ubuntu 22.04 + nvidia/cuda:12.4.1-devel + apt-deps):
|
||||
|
||||
```bash
|
||||
cd /home/claude/projects/cuframes-composer
|
||||
|
||||
# If builder not yet cached:
|
||||
docker image inspect cuframes-composer-builder:cached >/dev/null 2>&1 || {
|
||||
docker run -d --name cfc-builder-tmp \
|
||||
nvidia/cuda:12.4.1-devel-ubuntu22.04 sleep 3600
|
||||
docker exec cfc-builder-tmp bash -c '
|
||||
apt-get update -qq && apt-get install -y -qq --no-install-recommends \
|
||||
build-essential cmake git pkg-config \
|
||||
libpng-dev libfreetype-dev \
|
||||
libzmq3-dev libjson-c-dev libmosquitto-dev \
|
||||
libavformat-dev libavcodec-dev libavutil-dev'
|
||||
docker commit cfc-builder-tmp cuframes-composer-builder:cached
|
||||
docker rm -f cfc-builder-tmp
|
||||
}
|
||||
|
||||
# Actual build:
|
||||
docker run --rm --gpus all -v "$PWD":/src -w /src/build-jammy \
|
||||
cuframes-composer-builder:cached \
|
||||
bash -c 'cmake -DCMAKE_BUILD_TYPE=Release .. && make -j$(nproc)'
|
||||
```
|
||||
|
||||
Artifacts in `build-jammy/`.
|
||||
|
||||
### 2.3 Bake image (incremental — without `docker build`)
|
||||
|
||||
We don't use `docker build` (4GB CUDA pull on cache miss). Instead:
|
||||
|
||||
```bash
|
||||
docker rmi gx/cuframes-composer:0.11b-step1 -f 2>/dev/null
|
||||
CID=$(docker create gx/cuframes-composer:0.10)
|
||||
docker cp build-jammy/examples/grid_record "$CID":/usr/local/bin/grid_record
|
||||
docker cp build-jammy/src/libcuframes_composer.so.0.1.0 \
|
||||
"$CID":/usr/lib/x86_64-linux-gnu/libcuframes_composer.so.0
|
||||
docker cp docker/templates.json "$CID":/opt/templates.json
|
||||
docker cp docker/mqtt_overlays.json "$CID":/opt/mqtt_overlays.json
|
||||
docker commit \
|
||||
--change 'ENTRYPOINT ["/usr/local/bin/grid_record"]' \
|
||||
--change 'CMD ["--help"]' \
|
||||
--change 'ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,video' \
|
||||
"$CID" gx/cuframes-composer:0.11b-step1
|
||||
docker rm "$CID"
|
||||
```
|
||||
|
||||
Uses `gx/cuframes-composer:0.10` as base (with runtime deps already
|
||||
installed) + overlays fresh artifacts. Faster and no network traffic.
|
||||
|
||||
### 2.4 Build ONVIF image
|
||||
|
||||
```bash
|
||||
cd hosts/R9-88.23/docker/cctv/onvif
|
||||
docker build -t gx/cctv-onvif:0.6 -f Dockerfile .
|
||||
```
|
||||
|
||||
Python image, lightweight. If you change `server.py` — rebuild image
|
||||
(bump tag) + update `docker-compose.override.yml`.
|
||||
|
||||
## 3. Deploy
|
||||
|
||||
### 3.1 Production (R9-88.23)
|
||||
|
||||
```bash
|
||||
cd /home/claude/projects/localhost-infra/hosts/R9-88.23/docker/cctv
|
||||
docker compose -f docker-compose.yml \
|
||||
-f cuda-grid/docker-compose.override.yml \
|
||||
-f cuframes-composer/docker-compose.override.yml \
|
||||
-f onvif/docker-compose.override.yml \
|
||||
up -d cfc-grid
|
||||
```
|
||||
|
||||
Compose auto-recreates container if image tag changed in
|
||||
`docker-compose.override.yml`.
|
||||
|
||||
### 3.2 Verify post-deploy
|
||||
|
||||
```bash
|
||||
docker logs --tail 30 cfc-grid 2>&1 | grep -iE "loaded|template|pool|motion"
|
||||
|
||||
# Expect something like:
|
||||
# [cfc/loader] /opt/templates.json: loaded 9 templates
|
||||
# [cfc/composer] templates loaded: 9 (path='/opt/templates.json')
|
||||
# [cfc/composer] pool+ 'cam-parking' (frigate=parking_overview prio=100)
|
||||
# [cfc/composer] motion_mode=1 ttl=45000ms pool=4
|
||||
# [cfc/composer] grow → template='tpl_3' active=3
|
||||
```
|
||||
|
||||
### 3.3 Rollback
|
||||
|
||||
```bash
|
||||
sed -i 's|gx/cuframes-composer:0.11b-step1|gx/cuframes-composer:0.10|' \
|
||||
hosts/R9-88.23/docker/cctv/cuframes-composer/docker-compose.override.yml
|
||||
docker compose ... up -d cfc-grid
|
||||
```
|
||||
|
||||
## 4. Logs
|
||||
|
||||
### 4.1 Live tail
|
||||
|
||||
```bash
|
||||
docker logs -f --tail 50 cfc-grid
|
||||
docker logs -f --tail 30 cfc-grid-ffmpeg
|
||||
docker logs -f --tail 30 cuda-grid-mediamtx
|
||||
docker logs -f --tail 30 cctv-onvif
|
||||
docker logs -f --tail 30 cctv-frigate
|
||||
```
|
||||
|
||||
### 4.2 Telemetry patterns
|
||||
|
||||
| Marker | Meaning |
|
||||
|---|---|
|
||||
| `[grid_record] N kadrov, M IDR, X MB ...` | Composer successfully encoding every ~50 frames |
|
||||
| `[cfc/composer] grow → template='X'` | New template applied (growth, immediate) |
|
||||
| `[cfc/composer] shrink → template='X'` | New template applied after hysteresis (shrink) |
|
||||
| `[cfc/composer] manual override 'X' до +60000ms` | PTZ via ONVIF |
|
||||
| `[cfc/composer] manual override expired, возврат в motion-mode` | Auto-return after 60s |
|
||||
| `[cfc/mqtt-overlay/<id>] '<text>'` | MQTT-overlay received/rendered new text |
|
||||
| `[cfc/frigate] connected, subscribe 'frigate/events'` | Frigate subscriber connected |
|
||||
|
||||
### 4.3 When something breaks
|
||||
|
||||
| Symptom | Where to look |
|
||||
|---|---|
|
||||
| `src active=0 stale=0 dead=4` | cuframes-pub-* containers; check `docker ps` and network access to cameras |
|
||||
| `overlay 0 draw failed` | `cfc_overlay_text_rebuild_atlas` — usually invalid font or text |
|
||||
| RTSP stream not delivering | `cfc-grid-ffmpeg` logs; see §6.1 |
|
||||
| TV/ONVIF can't find | `cctv-onvif` logs; check multicast WS-Discovery in LAN |
|
||||
|
||||
## 5. Monitoring
|
||||
|
||||
### 5.1 MQTT health
|
||||
|
||||
`cfc-grid` publishes health to `cuda_grid/health/composer/cfc-grid`
|
||||
every ~10 seconds:
|
||||
|
||||
```json
|
||||
{
|
||||
"uptime_s": 3600,
|
||||
"frames_encoded": 90000,
|
||||
"fps_actual": 25.0,
|
||||
"bitrate_kbps": 6000,
|
||||
"src_active": 4,
|
||||
"src_stale": 0,
|
||||
"src_dead": 0,
|
||||
"idr_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
PW=$(grep '^COMPOSER_MQTT_PASSWORD=' \
|
||||
/home/claude/projects/localhost-infra/hosts/R9-88.23/docker/cctv/.env | cut -d= -f2)
|
||||
mosquitto_sub -h 192.168.88.23 -u composer -P "$PW" \
|
||||
-t 'cuda_grid/health/composer/cfc-grid' -v
|
||||
```
|
||||
|
||||
### 5.2 Watchdog
|
||||
|
||||
`cfc-grid-watchdog` is a separate service, monitors mediamtx
|
||||
`inboundBytes` for `cfc-grid` path. If **30 seconds of silence** —
|
||||
`docker restart cfc-grid`.
|
||||
|
||||
Watchdog logs:
|
||||
```bash
|
||||
docker logs --tail 30 cfc-grid-watchdog
|
||||
```
|
||||
|
||||
On trigger — publishes to `cuda_grid/health/watchdog/cfc-grid`.
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
### 6.1 RTSP not delivering / `cfc-grid-ffmpeg` "Broken pipe"
|
||||
|
||||
**Symptom:** `docker logs cfc-grid-ffmpeg` shows
|
||||
`[out#0/rtsp] Task finished with error code: -32 (Broken pipe)`.
|
||||
|
||||
**Cause:** `--intra-refresh` in composer (no IDR bursts), mediamtx
|
||||
tears RTSP publisher when it can't deliver start-frame to a new client.
|
||||
|
||||
**Treatment:**
|
||||
- Full pipeline restart:
|
||||
```bash
|
||||
docker compose ... restart cfc-grid-ffmpeg cfc-grid cuda-grid-mediamtx
|
||||
```
|
||||
- If recurring — disable `--intra-refresh` in compose override
|
||||
(cost: IDR bursts in bitrate, but more stable for downstream clients
|
||||
with frequent disconnect/reconnect)
|
||||
|
||||
### 6.2 ffmpeg doesn't receive frames from RTSP
|
||||
|
||||
**Symptom:** `ffmpeg -i rtsp://192.168.88.23:554/cfc-grid -frames:v 1 out.jpg`
|
||||
hangs for 30+ seconds.
|
||||
|
||||
**Cause:** Composer writes H.264 without regular IDR (intra-refresh).
|
||||
A new RTSP client waits for a keyframe to start decoding. ffmpeg in
|
||||
default config doesn't wait long enough.
|
||||
|
||||
**Workaround:**
|
||||
```bash
|
||||
ffmpeg -rtsp_transport tcp \
|
||||
-analyzeduration 10000000 -probesize 10000000 \
|
||||
-i rtsp://192.168.88.23:554/cfc-grid \
|
||||
-frames:v 1 -y out.jpg
|
||||
```
|
||||
|
||||
Or use HLS:
|
||||
```bash
|
||||
ffmpeg -i http://192.168.88.23:8888/cfc-grid/index.m3u8 \
|
||||
-frames:v 1 -y out.jpg
|
||||
```
|
||||
|
||||
### 6.3 MQTT-overlay not updating
|
||||
|
||||
**Checklist:**
|
||||
|
||||
1. Bridge to HA broker (192.168.88.4) working?
|
||||
```bash
|
||||
docker logs cctv-mosquitto 2>&1 | grep -i 'bridge'
|
||||
```
|
||||
Look for `Connecting bridge ha-bridge` and connect confirmation.
|
||||
|
||||
2. Required topic in bridge config?
|
||||
```bash
|
||||
docker exec cctv-mosquitto grep 'topic.*in 0' /mosquitto/config/mosquitto.conf
|
||||
```
|
||||
If new prefix — add `topic XXX/# in 0` and restart mosquitto.
|
||||
|
||||
3. Subscriber connected?
|
||||
```bash
|
||||
docker logs cfc-grid 2>&1 | grep 'mqtt-overlay/<id>.*connected'
|
||||
```
|
||||
|
||||
4. Test publish:
|
||||
```bash
|
||||
mosquitto_pub -h 192.168.88.4 -t '<your topic>' -m 'test' -r
|
||||
```
|
||||
In composer logs, should appear `[cfc/mqtt-overlay/<id>] 'test'`.
|
||||
|
||||
### 6.4 Motion-mode not switching layouts
|
||||
|
||||
**Checklist:**
|
||||
|
||||
1. Frigate sending events?
|
||||
```bash
|
||||
mosquitto_sub -h 192.168.88.23 -u composer -P "$PW" \
|
||||
-t 'frigate/events' -C 3
|
||||
```
|
||||
|
||||
2. Composer receiving events?
|
||||
```bash
|
||||
docker logs cfc-grid 2>&1 | grep 'frigate.*started\|grow\|shrink'
|
||||
```
|
||||
|
||||
3. Camera-name matches?
|
||||
`frigate=<name>` in `--source` must match `event.after.camera`.
|
||||
|
||||
4. Zone-filter not blocking?
|
||||
If `zones=A:B:C` in `--source` — check Frigate event `current_zones`.
|
||||
If empty or doesn't intersect — pulse is discarded.
|
||||
|
||||
5. TTL not expired?
|
||||
Logs `motion_ttl=45000` (45 sec) — if events come less frequently —
|
||||
camera drops from active.
|
||||
|
||||
### 6.5 ONVIF PTZ presets empty in TV
|
||||
|
||||
**Cause:** TV cached old `GetPresets` response (Phase 9 names).
|
||||
|
||||
**Treatment:** delete and re-add camera in TV client.
|
||||
|
||||
### 6.6 Templates loaded but motion-mode doesn't use new
|
||||
|
||||
Composer reads global registry `cfc::current_templates()` on every frame
|
||||
— changes via `cfc_layout_load_file` (ZMQ or CLI) should be picked up
|
||||
immediately. If not — check:
|
||||
|
||||
```bash
|
||||
echo '{"cmd":"list_layouts"}' | python3 -c "
|
||||
import zmq,json,sys
|
||||
s = zmq.Context().socket(zmq.REQ)
|
||||
s.connect('tcp://192.168.88.23:5599')
|
||||
s.send_json({'cmd':'list_layouts'})
|
||||
print(json.dumps(s.recv_json(), indent=2, ensure_ascii=False))"
|
||||
```
|
||||
|
||||
`source` field shows currently loaded path. If built-in (only `tpl_1` +
|
||||
`tpl_4`) — JSON didn't load (syntax error, wrong path).
|
||||
|
||||
## 7. Configs in repo
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| templates.json | `cuframes-composer/docker/templates.json` |
|
||||
| mqtt_overlays.json | `cuframes-composer/docker/mqtt_overlays.json` |
|
||||
| compose override | `localhost-infra/hosts/R9-88.23/docker/cctv/cuframes-composer/docker-compose.override.yml` |
|
||||
| ONVIF config | `localhost-infra/.../onvif/onvif.yaml` |
|
||||
| ONVIF server | `localhost-infra/.../onvif/server.py` |
|
||||
| Mosquitto config | `localhost-infra/.../cctv/mosquitto/config/mosquitto.conf` |
|
||||
| .env (passwords) | `localhost-infra/.../cctv/.env` (gitignored) |
|
||||
|
||||
After changing compose override — `docker compose ... up -d cfc-grid`
|
||||
auto-recreates.
|
||||
|
||||
## 8. Known limitations / TODO
|
||||
|
||||
- **`--intra-refresh` ↔ RTSP clients**: trade-off bitrate vs latency
|
||||
(see §6.1)
|
||||
- **Watchdog only for cfc-grid**: cfc-grid-ffmpeg in zombie state not
|
||||
detected directly; only full restart helps
|
||||
- **Hot-reload of mqtt_overlays.json**: no ZMQ verb
|
||||
- **Per-overlay MQTT broker config**: all via single broker; for
|
||||
foreign broker — need to extend `MqttBrokerCfg` per-item
|
||||
|
||||
## 9. See also
|
||||
|
||||
- [user.md](user.md) — composer configuration
|
||||
- [developer.md](developer.md) — internals, adding modules
|
||||
- `memory/host-and-project.md` — general R9-88.23 infra
|
||||
- `memory/project_cfc-grid-deployed.md` — first prod deploy
|
||||
- `memory/project_cfc-grid-cpp-refactor.md` — Phase 11b refactor
|
||||
- `memory/incremental-ffmpeg-rebuild.md` — incremental docker recipe
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
# cfc-grid — user guide
|
||||
|
||||
> Audience: installation admin. Configures cameras, layouts, overlays,
|
||||
> watches the RTSP stream on TV or browser. Does not edit C++ code.
|
||||
>
|
||||
> Developer extending Cell/Decoration/widget types → see
|
||||
> [developer.md](developer.md).
|
||||
|
||||
## 1. What this is
|
||||
|
||||
**cfc-grid** is a CUDA compositor that combines N cameras into a single
|
||||
RTSP stream `rtsp://192.168.88.23:554/cfc-grid` (1920×1080, H.264, NVENC).
|
||||
Layout is selected automatically by motion (from Frigate) or manually via
|
||||
ONVIF presets on the TV.
|
||||
|
||||
In addition to camera frames, the following are drawn on top:
|
||||
|
||||
- **Borders** — grey 2 px borders around each cell
|
||||
- **Labels** — `name prio=N` caption in the corner of each camera
|
||||
- **Detection boxes** — object rectangles from Frigate, tracking camera
|
||||
position when layout changes
|
||||
- **MQTT overlays** — text fields bound to MQTT topics (temperature,
|
||||
statuses, chats)
|
||||
|
||||
## 2. Architecture in one sentence
|
||||
|
||||
```
|
||||
cuframes-pub-* (per camera)
|
||||
↓ shared VMM
|
||||
cfc-grid (composer) ── ZMQ control ──┐
|
||||
↓ pipe (H.264) │
|
||||
cfc-grid-ffmpeg (relay) ─→ mediamtx ─┴─→ TV / VLC / Frigate / ...
|
||||
↑
|
||||
ONVIF discovery from cctv-onvif
|
||||
```
|
||||
|
||||
Frames go through cuframes zero-copy (a single VMM buffer shared between
|
||||
publisher and composer). The composer takes the NV12 surface, resizes/blits
|
||||
into its output, adds decorations, hands off to NVENC, NVENC writes H.264
|
||||
into a pipe, `cfc-grid-ffmpeg` re-muxes the pipe → RTSP push to mediamtx.
|
||||
|
||||
## 3. Motion mode — the main operating mode
|
||||
|
||||
### 3.1 What happens
|
||||
|
||||
Each frame the composer:
|
||||
|
||||
1. Reads `last_motion_ms` for each camera (updated from Frigate MQTT
|
||||
`frigate/events`)
|
||||
2. Treats as "active" any camera with `(now - last_motion_ms) < motion_ttl_ms`
|
||||
(default **45 seconds**)
|
||||
3. Sorts active by `priority` (integer; higher = more important)
|
||||
4. Selects template from `templates.json` by **best-fit** rule:
|
||||
minimal template with `nb_camera_cells >= active_count`
|
||||
5. If template has more camera-cells than active — extra ones are filled
|
||||
with remaining drawable cameras from pool (by priority)
|
||||
6. Applies **asymmetric hysteresis**: growth in active count switches
|
||||
layout immediately, shrinkage waits 3 seconds (to avoid flicker)
|
||||
|
||||
### 3.2 What "drawable" means
|
||||
|
||||
A camera is **excluded** from pool if its `cfc_source_state_t` =
|
||||
`CONNECTING`, `DISCONNECTED` or `DEAD` (cuframes publisher silent for
|
||||
longer than `dead_threshold_ms`, default 5 seconds).
|
||||
|
||||
`STALE` (frames arrive infrequently) — counts, last available frame is
|
||||
drawn.
|
||||
|
||||
### 3.3 Manual override via PTZ
|
||||
|
||||
In the TV, ONVIF PTZ presets list template names (`tpl_1, tpl_3, tpl_4,
|
||||
..., tpl_16`). Pressing `GotoPreset` or movement keys:
|
||||
|
||||
- Applies the chosen layout immediately
|
||||
- **Freezes** motion-mode for 60 seconds
|
||||
- After expiry — returns to auto mode
|
||||
|
||||
ContinuousMove (arrows): pan/tilt cycles through preset list, zoom-in =
|
||||
`tpl_1` (full screen), zoom-out = `tpl_16` (4×4 grid).
|
||||
|
||||
## 4. templates.json — screen layout
|
||||
|
||||
### 4.1 Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"grid_cols": 8,
|
||||
"grid_rows": 8,
|
||||
"templates": [
|
||||
{
|
||||
"name": "tpl_N",
|
||||
"_desc": "Description",
|
||||
"priority": 0,
|
||||
"cells": [
|
||||
{
|
||||
"col": 0, "row": 0,
|
||||
"cs": 4, "rs": 4,
|
||||
"role": "camera",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"col": 6, "row": 0,
|
||||
"cs": 2, "rs": 6,
|
||||
"role": "widget",
|
||||
"widget": "temp_chart"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Grid: 8×8 microcells** (240×135 px each = 16:9 on 1920×1080 output).
|
||||
Any N×N square of microcells is also 16:9.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `col`, `row` | Top-left corner of cell in microcells (0..7) |
|
||||
| `cs`, `rs` | Size in microcells |
|
||||
| `role` | `camera` or `widget` |
|
||||
| `order` | For `camera`: placement order for active cameras (0 = main, usually the largest cell) |
|
||||
| `widget` | For `widget`: placeholder name (caption text) |
|
||||
|
||||
### 4.2 Best-fit selection
|
||||
|
||||
The composer selects a template for current active count:
|
||||
|
||||
```
|
||||
candidates = [t for t in templates if t.nb_camera_cells >= n_active]
|
||||
pick = min(candidates, key=lambda t: t.nb_camera_cells - n_active)
|
||||
# On tie: higher priority wins
|
||||
```
|
||||
|
||||
If active count exceeds the largest template's cells — the largest is
|
||||
taken, extra cameras are dropped (lowest priority).
|
||||
|
||||
### 4.3 Built-in templates
|
||||
|
||||
By default 9 templates in `/opt/templates.json`:
|
||||
|
||||
| Name | Cells | Description |
|
||||
|---|---|---|
|
||||
| `tpl_1` | 1 cam | One camera fullscreen |
|
||||
| `tpl_3` | 3 cam + 2 widgets | Main 1440×810 + 2 previews + 2 widget areas |
|
||||
| `tpl_4` | 4 cam | Quad 2×2, 960×540 each |
|
||||
| `tpl_5` | 5 cam + 1 widget | Main + 4 previews stacked right + widget bottom |
|
||||
| `tpl_6` | 6 cam + 1 widget | Main + 3 right + 2 bottom + widget |
|
||||
| `tpl_7` | 7 cam + 1 widget | Main + 3 right + 3 bottom + widget |
|
||||
| `tpl_8` | 8 cam (1+3+4) | Main + 3 right + 4 bottom row |
|
||||
| `tpl_9` | 9 cam + 2 widgets | 3×3 mains + widget strips |
|
||||
| `tpl_16` | 16 cam | 4×4 grid, 480×270 each |
|
||||
|
||||
Details — see `docker/templates.json` in the repo.
|
||||
|
||||
### 4.4 Adding your own template
|
||||
|
||||
1. Open `docker/templates.json` (or mounted override)
|
||||
2. Add a block to `"templates": [...]` per schema above
|
||||
3. Restart cfc-grid (or invoke ZMQ when hot-reload is available — Phase 12)
|
||||
|
||||
### 4.5 Coordinate math
|
||||
|
||||
Each microcell = `1920/8 = 240 px` wide, `1080/8 = 135 px` tall (16:9).
|
||||
Cell `{col=2, row=4, cs=4, rs=2}`:
|
||||
|
||||
- pixel x = `2 * 240 = 480`
|
||||
- pixel y = `4 * 135 = 540`
|
||||
- pixel w = `4 * 240 = 960`
|
||||
- pixel h = `2 * 135 = 270`
|
||||
|
||||
Aspect cell = `cs/rs * 16/9`. For 16:9 cells: **cs == rs**.
|
||||
|
||||
## 5. mqtt_overlays.json — text overlays from MQTT
|
||||
|
||||
### 5.1 Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"overlays": [
|
||||
{
|
||||
"id": "temp_outside",
|
||||
"topic": "zigbee2mqtt/Outdoor temp sensor",
|
||||
"json_field": "temperature",
|
||||
"format": "%+.1f°C",
|
||||
"anchor": "right-bottom",
|
||||
"margin_x": 32, "margin_y": 24,
|
||||
"pixel_size": 32,
|
||||
"color": [255, 255, 255],
|
||||
"alpha": 230,
|
||||
"bg_alpha": 160,
|
||||
"bg_y": 16, "bg_u": 128, "bg_v": 128,
|
||||
"bg_pad": 10,
|
||||
"placeholder": "—",
|
||||
"font_path": "/fonts/DejaVuSans-Bold.ttf"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `id` | Unique overlay identifier (used by ZMQ for lookup) |
|
||||
| `topic` | MQTT topic to subscribe (via `cctv-mosquitto`) |
|
||||
| `json_field` | If payload is JSON — field name to extract; **empty = raw payload as string** |
|
||||
| `format` | `printf`-style for the formatted value (e.g. `"%+.1f°C"`, `"%s"`) |
|
||||
| `anchor` | Positioning anchor: `right-bottom`, `right-top`, `left-bottom`, `left-top`, `center` |
|
||||
| `margin_x`, `margin_y` | Offset from nearest screen edge (px) |
|
||||
| `pixel_size` | Font size in pixels |
|
||||
| `color` | RGB text color |
|
||||
| `alpha` | Overall text opacity (0..255) |
|
||||
| `bg_alpha` | Background opacity (0..255); **0 = no background** |
|
||||
| `bg_y`, `bg_u`, `bg_v` | BT.709 limited-range background color; default black |
|
||||
| `bg_pad` | Background padding around text (px) |
|
||||
| `placeholder` | What to show before first MQTT message; empty = "—" |
|
||||
| `font_path` | Path to font (.ttf/.otf) inside container |
|
||||
|
||||
### 5.2 Supported symbols
|
||||
|
||||
Font `DejaVuSans-Bold.ttf` — standard from `fonts-dejavu` package.
|
||||
**Covers Basic Multilingual Plane** (latin, cyrillic, basic symbols),
|
||||
including:
|
||||
|
||||
- `❯` (U+276F), `✎` (U+270E), `➤` (U+27A4), `→` (U+2192)
|
||||
- `★` (U+2605), `▶` (U+25B6), `✉` (U+2709)
|
||||
|
||||
**Emoji from Supplementary Multilingual Plane (>U+10000)** — e.g.
|
||||
`🗣` (U+1F5E3), `🤖` (U+1F916), `💬` (U+1F4AC) — **are not rendered**:
|
||||
font lacks those glyphs. Placeholder square is drawn instead.
|
||||
|
||||
To add color emoji — bind Noto Color Emoji and extend renderer for
|
||||
COLR/CPAL/SBIX (see developer.md).
|
||||
|
||||
### 5.3 Examples
|
||||
|
||||
**Plain number**: payload = `"23.5"` (raw), `json_field: ""`,
|
||||
`format: "%s°"`
|
||||
|
||||
**JSON with field**: payload = `{"temperature": 23.5, "humidity": 45}`,
|
||||
`json_field: "temperature"`, `format: "%+.1f°C"`
|
||||
|
||||
**Multiple overlays** stacked top-right: first with `margin_y: 24`,
|
||||
second `margin_y: 72`, third `margin_y: 120`.
|
||||
|
||||
### 5.4 How to add
|
||||
|
||||
1. Open `docker/mqtt_overlays.json` (or mounted override)
|
||||
2. Add block to `"overlays": [...]` array
|
||||
3. Restart cfc-grid
|
||||
|
||||
Hot-reload via ZMQ — Phase 12 (`reload_overlays` verb).
|
||||
|
||||
## 6. Composer CLI flags
|
||||
|
||||
In compose override `docker/cctv/cuframes-composer/docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- "--out=/out/grid.h264" # named pipe for ffmpeg-relay
|
||||
- "--fps=25"
|
||||
- "--bitrate=6000" # kbps
|
||||
- "--width=1920"
|
||||
- "--height=1080"
|
||||
- "--intra-refresh" # instead of IDR-bursts (low-latency)
|
||||
- "--control=tcp://0.0.0.0:5599" # ZMQ control plane
|
||||
- "--mqtt=cctv-mosquitto:1883" # MQTT for health publishing
|
||||
- "--mqtt-instance=cfc-grid"
|
||||
- "--mqtt-user=composer"
|
||||
- "--mqtt-pass=${COMPOSER_MQTT_PASSWORD}"
|
||||
|
||||
# Sources (cameras) — repeatable
|
||||
- "--source=cam-parking,frigate=parking_overview,priority=100,zones=parking_zone:canopy:private_area"
|
||||
- "--source=cam-back_yard,frigate=back_yard,priority=70"
|
||||
# ...
|
||||
|
||||
- "--motion-mode" # enable auto-layout
|
||||
- "--motion-ttl=45000" # ms
|
||||
|
||||
- "--templates=/opt/templates.json"
|
||||
- "--mqtt-overlays=/opt/mqtt_overlays.json"
|
||||
|
||||
# Frigate motion-driver and detection boxes
|
||||
- "--frigate-mqtt=cctv-mosquitto:1883"
|
||||
- "--frigate-topic=frigate/events"
|
||||
- "--detection-cell=parking,parking_overview,0,0,960,540,640,480,parking_zone:canopy:private_area"
|
||||
```
|
||||
|
||||
### 6.1 `--source` syntax
|
||||
|
||||
```
|
||||
--source=<cuframes_key>,frigate=<camera>,priority=<N>[,zones=<z1>:<z2>:...]
|
||||
```
|
||||
|
||||
- `cuframes_key` — name of cuframes publisher (e.g. `cam-parking`)
|
||||
- `frigate=NAME` — camera name in Frigate (for motion event matching)
|
||||
- `priority=N` — integer, higher = more important
|
||||
- `zones=...` — optional whitelist of Frigate zones; motion counts
|
||||
only if `event.current_zones` intersects the list (filters street flood)
|
||||
|
||||
### 6.2 `--detection-cell` syntax
|
||||
|
||||
```
|
||||
--detection-cell=<key>,<frigate_camera>,<x>,<y>,<w>,<h>,<detect_w>,<detect_h>[,zones]
|
||||
```
|
||||
|
||||
- `key` — arbitrary overlay identifier for logs
|
||||
- `frigate_camera` — Frigate name (for `event.camera` matching)
|
||||
- `x,y,w,h` — initial geometry (composer recalculates dynamically)
|
||||
- `detect_w,detect_h` — Frigate detector resolution (e.g. 640×480)
|
||||
- `zones` — bbox whitelist
|
||||
|
||||
## 7. ZMQ control plane
|
||||
|
||||
Default endpoint: `tcp://192.168.88.23:5599`. All verbs — JSON request/reply.
|
||||
|
||||
### 7.1 Verb list
|
||||
|
||||
| Command | Parameters | What it does |
|
||||
|---|---|---|
|
||||
| `ping` | — | Health check |
|
||||
| `health` | — | `{total, active, stale, dead}` over pool |
|
||||
| `set_text` | `id, text, r, g, b, x, y, visible` | Update text overlay (for CLI `--text=...`) |
|
||||
| `set_visible` | `id, visible` | Hide/show overlay |
|
||||
| `list_overlays` | — | List of overlays |
|
||||
| `set_layout` | `name` | Apply named template (manual override 60s in motion-mode) |
|
||||
| `list_layouts` | — | List of templates with cells |
|
||||
| `get_layout` | — | Name of current template |
|
||||
| `set_motion_mode` | `on, ttl_ms` | Toggle motion mode |
|
||||
| `get_motion_mode` | — | Motion mode state |
|
||||
| `get_template` | `name` | Full template JSON |
|
||||
| `reload_templates` | `path?` | Reload templates from file |
|
||||
|
||||
### 7.2 Example
|
||||
|
||||
```bash
|
||||
python3 <<EOF
|
||||
import zmq, json
|
||||
s = zmq.Context().socket(zmq.REQ)
|
||||
s.connect("tcp://192.168.88.23:5599")
|
||||
s.send_json({"cmd": "list_layouts"})
|
||||
print(json.dumps(s.recv_json(), indent=2, ensure_ascii=False))
|
||||
EOF
|
||||
```
|
||||
|
||||
Plain `nc` **does not work** — REP socket expects ZMQ wire-protocol.
|
||||
Use `zmq` Python/Go/JS or `mosquitto_pub` via RPC-bridge (Phase 12).
|
||||
|
||||
## 8. ONVIF and PTZ
|
||||
|
||||
`cctv-onvif` service binds to host network, responds to WS-Discovery
|
||||
multicast (239.255.255.250:3702) and SOAP requests over HTTP `:8085`.
|
||||
|
||||
### 8.1 Adding to TV
|
||||
|
||||
In the client (TV / IP-CamViewer / Synology / Frigate):
|
||||
|
||||
- ONVIF host: `192.168.88.23`
|
||||
- Port: `8085`
|
||||
- User / Password: empty (auth not configured)
|
||||
|
||||
WS-Discovery in LAN 192.168.88.0/24 finds device `cfc-grid (Goldix)`.
|
||||
RTSP URL is automatic — `rtsp://192.168.88.23:554/cfc-grid`.
|
||||
|
||||
### 8.2 PTZ presets
|
||||
|
||||
List (`GetPresets`): `tpl_1, tpl_3, tpl_4, tpl_5, tpl_6, tpl_7, tpl_8,
|
||||
tpl_9, tpl_16`.
|
||||
|
||||
GotoPreset(name) → composer applies template + freezes motion-mode for
|
||||
60 seconds → auto-return.
|
||||
|
||||
### 8.3 PTZ movement (ContinuousMove)
|
||||
|
||||
| Command | Action |
|
||||
|---|---|
|
||||
| Pan right / Tilt down | Next template in list |
|
||||
| Pan left / Tilt up | Previous |
|
||||
| Zoom in (+) | `tpl_1` (full screen) |
|
||||
| Zoom out (−) | `tpl_16` (4×4 grid) |
|
||||
|
||||
## 9. Where to view RTSP
|
||||
|
||||
| Method | URL |
|
||||
|---|---|
|
||||
| VLC / mpv / ffplay | `rtsp://192.168.88.23:554/cfc-grid` |
|
||||
| Browser (HLS) | `http://192.168.88.23:8888/cfc-grid` |
|
||||
| WebRTC | `http://192.168.88.23:8889/cfc-grid` |
|
||||
| OBS / FFmpeg input | `rtsp://192.168.88.23:554/cfc-grid` |
|
||||
| ONVIF clients | via WS-Discovery (see §8) |
|
||||
|
||||
## 10. Known limitations
|
||||
|
||||
- **Color emoji not rendered** (needs Noto Color Emoji + COLR/CPAL
|
||||
support in text renderer — Phase 12)
|
||||
- **Hot-reload `mqtt_overlays.json`** — no ZMQ verb, requires cfc-grid
|
||||
restart
|
||||
- **Per-overlay broker** — all MQTT overlays use the common broker
|
||||
(set as `--mqtt`); subscribing to a foreign broker separately —
|
||||
not yet
|
||||
- **Widget rendering** — placeholder (dark rect + label), real widgets
|
||||
(graph, chat) — Phase 12+
|
||||
|
||||
## 11. FAQ
|
||||
|
||||
**Q: TV shows old layout names (`quad`, `dual_horizontal`). What to do?**
|
||||
|
||||
A: TV cached ONVIF presets. In the client, delete the camera and add
|
||||
again — it will re-read `GetPresets` with current names.
|
||||
|
||||
**Q: Parking camera is DEAD but logs show active=3. Why?**
|
||||
|
||||
A: `cfc_composer_get_health` shows **pool-wide** state, but motion-active
|
||||
counts by `last_motion_ms` independently of source state. DEAD is excluded
|
||||
in `is_camera_drawable()` inside `compose_motion_relayout`.
|
||||
|
||||
**Q: Pressed PTZ on TV, layout switched, but reverted after a minute.**
|
||||
|
||||
A: This is by design — `set_layout` in motion-mode freezes auto for
|
||||
60 seconds (`manual_override_duration_ms`). To pin layout permanently —
|
||||
disable motion-mode entirely via ZMQ:
|
||||
|
||||
```json
|
||||
{"cmd": "set_motion_mode", "on": 0}
|
||||
```
|
||||
|
||||
**Q: I want text overlay backgrounds in different colors.**
|
||||
|
||||
A: `bg_y/bg_u/bg_v` fields accept BT.709 limited-range. For red — Y≈80,
|
||||
U≈90, V≈240. For cyan — Y≈170, U≈170, V≈100. Calculator:
|
||||
https://www.rapidtables.com/convert/color/rgb-to-yuv.html
|
||||
(use BT.709 limited).
|
||||
|
||||
**Q: With motion on 5 cameras, the layout doesn't change, stays at quad.**
|
||||
|
||||
A: Check `docker logs cfc-grid | grep "loaded N templates"` — must be ≥5
|
||||
(should include `tpl_5..tpl_8`, `tpl_9`, `tpl_16`). If not — templates.json
|
||||
didn't load (check syntax via `jq` or `python3 -m json.tool`).
|
||||
|
||||
**Q: Frigate bbox is drawn on the wrong camera.**
|
||||
|
||||
A: Check `--detection-cell` — `frigate_camera` must match
|
||||
`event.after.camera`. Composer binds detbox-overlay to pool-entry via
|
||||
`frigate_camera` (see `cfc_composer::pool::by_frigate_camera`).
|
||||
|
||||
## 12. Next
|
||||
|
||||
- [developer.md](developer.md) — internals, extension
|
||||
- [operations.md](operations.md) — build, deploy, troubleshooting
|
||||
- repo README: brief overview
|
||||
@@ -0,0 +1,615 @@
|
||||
# cfc-grid — руководство разработчика
|
||||
|
||||
> Аудитория: разработчик который правит C++ код композитора, добавляет
|
||||
> новые типы Cell/Decoration/overlay-type, или меняет логику auto-layout.
|
||||
>
|
||||
> Если ты пользователь — см. [user.md](user.md). Если занимаешься
|
||||
> deploy/troubleshooting — см. [operations.md](operations.md).
|
||||
|
||||
## 1. Архитектура (40000-foot view)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ cfc::Composer (C++) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||
│ │SourcePool│ │ Layout │ │OutputSurface │ │
|
||||
│ │ (pool of │ │ vector< │ │ CudaBuffer │ │
|
||||
│ │ cfc_ │ │ unique_ │ │ (VMM NV12) │ │
|
||||
│ │source_t*)│ │ ptr<Cell>│ │ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────────┘ │
|
||||
│ ↓ ↓ ↑ │
|
||||
│ motion_pulse apply() NVENC │
|
||||
└──────────────────────────────────────────────┘
|
||||
↑ ↓
|
||||
extern "C" ABI shim H.264 pipe
|
||||
(composer_c_api.cpp, ↓
|
||||
layouts_c_api.cpp, ffmpeg-relay
|
||||
mqtt_overlay_c_api.cpp) ↓
|
||||
↑ mediamtx
|
||||
┌────────┴────────┐ ↓
|
||||
│ │ ↓
|
||||
grid_record.c control.c clients
|
||||
(C, CLI parsing) (ZMQ verbs)
|
||||
↑ ↑
|
||||
│ │
|
||||
frigate_mqtt.c health.c, audio.c, writer.c, source.c
|
||||
(Frigate events) (C-only modules)
|
||||
```
|
||||
|
||||
**Принципы:**
|
||||
|
||||
- **C++17 host-side**, CUDA kernels остались на C
|
||||
- **Zero-copy:** output VMM-буфер передаётся как `NV12Ref` всем
|
||||
cells/decorations (read-write reference, не копия)
|
||||
- **Coexistence:** C-модули (`source.c`, `nvenc.c`, `frigate_mqtt.c`,
|
||||
`health.c`, `writer.c`, `audio.c`, `overlay.c`) сохранены, доступ через
|
||||
`extern "C"`. Только `composer.c` и `layouts.c` удалены (заменены C++ +
|
||||
ABI shim).
|
||||
- **`grid_record.c`** (CLI entry-point) остался на C, использует
|
||||
публичный `cfc_composer_*` API без правок
|
||||
|
||||
## 2. Дерево исходников
|
||||
|
||||
```
|
||||
include/cuframes_composer/
|
||||
├── *.h # Публичный C API (extern "C")
|
||||
│ ├── composer.h # cfc_composer_* ABI
|
||||
│ ├── layouts.h # cfc_layout_* ABI
|
||||
│ ├── overlay.h # cfc_overlay_* (text/png/border/detbox)
|
||||
│ ├── source.h, nvenc.h, frigate_mqtt.h, ...
|
||||
│ └── ...
|
||||
└── cpp/ # C++ headers — публичные классы
|
||||
├── cuda_raii.hpp # CudaBuffer, CudaStream (RAII)
|
||||
├── types.hpp # Rect, NV12Ref
|
||||
├── cell.hpp # абстрактный Cell + draw()
|
||||
├── camera_cell.hpp # CameraCell : Cell
|
||||
├── widget_cell.hpp # WidgetCell : Cell
|
||||
├── blank_cell.hpp # BlankCell : Cell
|
||||
├── decoration.hpp # абстрактный Decoration
|
||||
├── border_decoration.hpp # BorderDecoration
|
||||
├── label_decoration.hpp # LabelDecoration (FreeType internal)
|
||||
├── template.hpp # LayoutTemplate, CellTemplate, kGridCols/Rows
|
||||
├── template_loader.hpp # JSON parser, current_templates()
|
||||
├── source_pool.hpp # SourcePool, PoolEntry
|
||||
├── layout.hpp # Layout
|
||||
├── composer.hpp # Composer (главный класс)
|
||||
└── mqtt_overlay.hpp # MqttOverlayItem + Manager
|
||||
|
||||
src/
|
||||
├── *.c # C-модули (source/nvenc/health/...)
|
||||
├── overlay.c # Все типы cfc_overlay_t
|
||||
├── frigate_mqtt.c # Subscribe + motion_pulse + zone-filter
|
||||
├── cugrid/cugrid.cu # CUDA kernels (resize, fill, blit)
|
||||
└── cpp/ # C++ реализации
|
||||
├── camera_cell.cpp, widget_cell.cpp, blank_cell.cpp
|
||||
├── border_decoration.cpp, label_decoration.cpp
|
||||
├── source_pool.cpp
|
||||
├── template_loader.cpp
|
||||
├── layout.cpp
|
||||
├── composer.cpp
|
||||
├── composer_c_api.cpp # extern "C" shim для composer
|
||||
├── layouts_c_api.cpp # extern "C" shim для layouts
|
||||
├── mqtt_overlay.cpp
|
||||
└── mqtt_overlay_c_api.cpp
|
||||
|
||||
examples/
|
||||
├── simple_record.c # 1-источник → H.264 в файл
|
||||
├── grid_record.c # Main CLI entry-point (C)
|
||||
└── grid_record_cpp.cpp # C++ smoke (использует cfc::Composer напрямую)
|
||||
```
|
||||
|
||||
## 3. Жизненный цикл одного кадра
|
||||
|
||||
```cpp
|
||||
// В compose loop (grid_record.c, через cfc_composer_compose):
|
||||
NV12Ref ref = composer.compose_frame();
|
||||
// 1. maybe_relayout(): motion-mode? best-fit template; apply Layout
|
||||
// 2. compose_clear(): fill output буфера BT.709 black
|
||||
// 3. Layout::render(): for each cell: draw_content() + decorations[]
|
||||
// 4. for each overlay: sync detbox geom + cfc_overlay_draw()
|
||||
// cudaStreamSynchronize(0);
|
||||
// nvenc.encode(ref.y_ptr, ref.pitch_y, ref.uv_ptr, ref.pitch_uv, ...);
|
||||
```
|
||||
|
||||
Все операции выполняются на CUDA default stream. Zero-copy:
|
||||
`ref.y_ptr` / `ref.uv_ptr` — те же `CUdeviceptr`, что внутри
|
||||
`composer.output_` (RAII `CudaBuffer`).
|
||||
|
||||
## 4. Cell — иерархия и расширение
|
||||
|
||||
### 4.1 Абстракция
|
||||
|
||||
```cpp
|
||||
class Cell {
|
||||
public:
|
||||
explicit Cell(const Rect& geom);
|
||||
virtual ~Cell() = default;
|
||||
Cell(const Cell&) = delete;
|
||||
|
||||
const Rect& geometry() const noexcept;
|
||||
void set_geometry(const Rect& r) noexcept;
|
||||
void add_decoration(std::unique_ptr<Decoration>);
|
||||
|
||||
void draw(CUstream stream, NV12Ref& dst); // public — calls draw_content() + decorations
|
||||
|
||||
protected:
|
||||
virtual void draw_content(CUstream stream, NV12Ref& dst) = 0;
|
||||
Rect geom_;
|
||||
std::vector<std::unique_ptr<Decoration>> decorations_;
|
||||
};
|
||||
```
|
||||
|
||||
`Cell::draw()` финальный (не виртуальный — final pattern через NVI):
|
||||
```cpp
|
||||
void Cell::draw(CUstream stream, NV12Ref& dst) {
|
||||
if (geom_.empty()) return;
|
||||
draw_content(stream, dst); // subclass renders content
|
||||
for (auto& d : decorations_) {
|
||||
d->draw(stream, dst, geom_); // overlay decorations on top
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Существующие подклассы
|
||||
|
||||
| Класс | draw_content реализация |
|
||||
|---|---|
|
||||
| `CameraCell` | `cfc_source_get_latest()` + `cfc_cugrid_resize_nv12` |
|
||||
| `WidgetCell` | `cfc_cugrid_fill_nv12` тёмно-серым Y=40 (placeholder) |
|
||||
| `BlankCell` | `cfc_cugrid_fill_nv12` BT.709 черным Y=16 |
|
||||
|
||||
### 4.3 Как добавить новый тип Cell
|
||||
|
||||
Пример: `GraphCell` — рисует scrolling-chart из подписки на MQTT.
|
||||
|
||||
1. Создать `include/cuframes_composer/cpp/graph_cell.hpp`:
|
||||
|
||||
```cpp
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_GRAPH_CELL_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_GRAPH_CELL_HPP
|
||||
#include "cell.hpp"
|
||||
#include <deque>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
class GraphCell : public Cell {
|
||||
public:
|
||||
GraphCell(const Rect& geom, std::size_t max_points = 60)
|
||||
: Cell(geom), max_points_(max_points) {}
|
||||
|
||||
void push_value(double v) {
|
||||
if (values_.size() >= max_points_) values_.pop_front();
|
||||
values_.push_back(v);
|
||||
}
|
||||
|
||||
protected:
|
||||
void draw_content(CUstream stream, NV12Ref& dst) override;
|
||||
|
||||
private:
|
||||
std::deque<double> values_;
|
||||
std::size_t max_points_;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
#endif
|
||||
```
|
||||
|
||||
2. Реализация `src/cpp/graph_cell.cpp`:
|
||||
|
||||
```cpp
|
||||
#include "../../include/cuframes_composer/cpp/graph_cell.hpp"
|
||||
#include "../../include/cuframes_composer/cugrid.h"
|
||||
|
||||
namespace cfc {
|
||||
|
||||
void GraphCell::draw_content(CUstream stream, NV12Ref& dst) {
|
||||
if (geom_.empty() || values_.empty()) return;
|
||||
|
||||
// 1. BG fill
|
||||
cfc_cugrid_fill_nv12(stream, dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
geom_.x, geom_.y, geom_.w, geom_.h,
|
||||
/*Y*/ 30, 128, 128, 255);
|
||||
|
||||
// 2. Compute min/max
|
||||
double mn = *std::min_element(values_.begin(), values_.end());
|
||||
double mx = *std::max_element(values_.begin(), values_.end());
|
||||
if (mx == mn) mx = mn + 1.0;
|
||||
|
||||
// 3. Render line as thin filled rects (lazy, без kernel'я для bitmap'а)
|
||||
int n = static_cast<int>(values_.size());
|
||||
int dx = geom_.w / n;
|
||||
for (int i = 0; i < n; i++) {
|
||||
double norm = (values_[i] - mn) / (mx - mn);
|
||||
int y_px = geom_.y + geom_.h - 2 - (int)(norm * (geom_.h - 4));
|
||||
cfc_cugrid_fill_nv12(stream, dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
geom_.x + i * dx, y_px, dx & ~1, 2,
|
||||
/*Y*/ 235, 128, 64, 255); // limited-range bright
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
```
|
||||
|
||||
3. Добавить в `src/CMakeLists.txt`:
|
||||
```cmake
|
||||
set(COMPOSER_SOURCES_CPP
|
||||
...
|
||||
cpp/graph_cell.cpp
|
||||
)
|
||||
```
|
||||
|
||||
4. Использовать в `Layout::apply()`:
|
||||
|
||||
```cpp
|
||||
// В layout.cpp, в обработке widget cells:
|
||||
if (wt->widget == "graph_temp") {
|
||||
cells_.push_back(std::make_unique<GraphCell>(r, /*max_points=*/120));
|
||||
} else {
|
||||
cells_.push_back(std::make_unique<WidgetCell>(r, wt->widget));
|
||||
}
|
||||
```
|
||||
|
||||
5. Подключить MQTT → `GraphCell::push_value()` — либо через MqttOverlayManager
|
||||
расширение, либо через `cfc::Composer::register_graph_feed()` (новый API).
|
||||
|
||||
## 5. Decoration — композиция в Cell
|
||||
|
||||
### 5.1 Абстракция
|
||||
|
||||
```cpp
|
||||
class Decoration {
|
||||
public:
|
||||
virtual ~Decoration() = default;
|
||||
virtual void draw(CUstream stream, NV12Ref& dst, const Rect& parent_rect) = 0;
|
||||
};
|
||||
```
|
||||
|
||||
Decoration знает только pixel-rect parent-cell'а — позиционируется
|
||||
**относительно него**. Не имеет access к Layout/Composer.
|
||||
|
||||
### 5.2 Существующие подклассы
|
||||
|
||||
- **`LabelDecoration`** — FreeType atlas + `cfc_cugrid_blit_rgba_nv12`.
|
||||
Persistent VRAM (rebuild при `set_text`). Поддерживает background fill
|
||||
(через `cfc_overlay_text_config_t.bg_*` параметры).
|
||||
- **`BorderDecoration`** — 4 вызова `cfc_cugrid_fill_nv12` (top, bottom,
|
||||
left, right rect'ы по `thickness`).
|
||||
|
||||
### 5.3 Как добавить новый тип Decoration
|
||||
|
||||
Пример: `BadgeDecoration` — иконка в углу (например красная точка
|
||||
«recording»).
|
||||
|
||||
1. Header `badge_decoration.hpp`:
|
||||
|
||||
```cpp
|
||||
class BadgeDecoration : public Decoration {
|
||||
public:
|
||||
struct Style {
|
||||
int corner = 0; // 0=top-left, 1=top-right, 2=bottom-right, 3=bottom-left
|
||||
int size = 12;
|
||||
int margin = 8;
|
||||
int color_y = 80, color_u = 90, color_v = 240; // red-ish
|
||||
int alpha = 240;
|
||||
bool visible = true;
|
||||
};
|
||||
explicit BadgeDecoration(const Style& s) : style_(s) {}
|
||||
void set_visible(bool v) noexcept { style_.visible = v; }
|
||||
void draw(CUstream stream, NV12Ref& dst, const Rect& p) override;
|
||||
private:
|
||||
Style style_;
|
||||
};
|
||||
```
|
||||
|
||||
2. Реализация:
|
||||
|
||||
```cpp
|
||||
void BadgeDecoration::draw(CUstream s, NV12Ref& dst, const Rect& p) {
|
||||
if (!style_.visible || style_.alpha <= 0) return;
|
||||
int x, y;
|
||||
switch (style_.corner) {
|
||||
case 0: x = p.x + style_.margin; y = p.y + style_.margin; break;
|
||||
case 1: x = p.x + p.w - style_.size - style_.margin; y = p.y + style_.margin; break;
|
||||
case 2: x = p.x + p.w - style_.size - style_.margin; y = p.y + p.h - style_.size - style_.margin; break;
|
||||
case 3: x = p.x + style_.margin; y = p.y + p.h - style_.size - style_.margin; break;
|
||||
}
|
||||
x &= ~1; y &= ~1;
|
||||
cfc_cugrid_fill_nv12(s, dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
x, y, style_.size, style_.size,
|
||||
style_.color_y, style_.color_u, style_.color_v, style_.alpha);
|
||||
}
|
||||
```
|
||||
|
||||
3. Подключать в `Layout::apply()`:
|
||||
|
||||
```cpp
|
||||
if (cc->source()->state() == CFC_SOURCE_STALE) {
|
||||
BadgeDecoration::Style bs;
|
||||
bs.corner = 1; bs.color_y = 180; bs.color_v = 100; // yellow
|
||||
cell->add_decoration(std::make_unique<BadgeDecoration>(bs));
|
||||
}
|
||||
```
|
||||
|
||||
## 6. SourcePool и motion-state
|
||||
|
||||
```cpp
|
||||
struct PoolEntry {
|
||||
std::string cuframes_key;
|
||||
std::string frigate_camera;
|
||||
int priority;
|
||||
cfc_source_t* source;
|
||||
std::atomic<int64_t> last_motion_ms;
|
||||
std::vector<std::string> required_zones;
|
||||
|
||||
cfc_source_state_t state() const; // вызывает cfc_source_get_latest
|
||||
bool drawable() const; // ACTIVE || STALE
|
||||
};
|
||||
|
||||
class SourcePool {
|
||||
public:
|
||||
int add(key, frigate_camera, priority, zones, SubscribeOpts);
|
||||
PoolEntry* by_key(const std::string&);
|
||||
PoolEntry* by_frigate_camera(const std::string&);
|
||||
template<typename F> void for_each(F&&);
|
||||
void motion_pulse(const std::string& frigate_camera,
|
||||
const std::vector<std::string>& current_zones);
|
||||
};
|
||||
```
|
||||
|
||||
`motion_pulse` вызывается из `frigate_mqtt.c` при каждом event. Если
|
||||
`required_zones` непустой — match по intersection с `event.current_zones`,
|
||||
иначе принимаем всё.
|
||||
|
||||
## 7. Auto-layout алгоритмы
|
||||
|
||||
См. `cfc::Composer::maybe_relayout()` (src/cpp/composer.cpp).
|
||||
|
||||
### 7.1 Best-fit selection
|
||||
|
||||
```cpp
|
||||
const LayoutTemplate* Composer::pick_best_fit(int need) const {
|
||||
const auto& reg = current_templates();
|
||||
const LayoutTemplate* best = nullptr;
|
||||
int best_waste = -1, best_prio = -1;
|
||||
for (auto& t : reg) {
|
||||
int n = t.nb_camera_cells();
|
||||
if (n < need) continue;
|
||||
int waste = n - need;
|
||||
if (!best || waste < best_waste ||
|
||||
(waste == best_waste && t.priority > best_prio)) {
|
||||
best = &t; best_waste = waste; best_prio = t.priority;
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
// overflow → largest
|
||||
for (auto& t : reg) {
|
||||
if (!best || t.nb_camera_cells() > best->nb_camera_cells()) best = &t;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Collect active
|
||||
|
||||
```cpp
|
||||
std::vector<PoolEntry*> Composer::collect_active() const {
|
||||
std::vector<PoolEntry*> active;
|
||||
int64_t now = now_ms_mono();
|
||||
pool_.for_each([&](PoolEntry& e) {
|
||||
if (!e.drawable()) return; // DEAD/CONNECTING — skip
|
||||
int64_t last = e.last_motion_ms.load();
|
||||
if (last == 0) return; // never had motion
|
||||
if (now - last > cfg_.motion_ttl_ms) return; // TTL expired
|
||||
active.push_back(&e);
|
||||
});
|
||||
// idle fallback: top-priority drawable
|
||||
if (active.empty()) {
|
||||
PoolEntry* best = nullptr;
|
||||
pool_.for_each([&](PoolEntry& e) {
|
||||
if (!e.drawable()) return;
|
||||
if (!best || e.priority > best->priority) best = &e;
|
||||
});
|
||||
if (best) active.push_back(best);
|
||||
}
|
||||
std::sort(active.begin(), active.end(),
|
||||
[](PoolEntry* a, PoolEntry* b) { return a->priority > b->priority; });
|
||||
return active;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 Asymmetric hysteresis
|
||||
|
||||
Сигнатура `template_name + "|" + sorted_keys` запоминается. Если новый
|
||||
набор ⊇ committed (рост) → commit мгновенно. Иначе ждём
|
||||
`shrink_hysteresis_ms` (default 3000) → commit.
|
||||
|
||||
```cpp
|
||||
bool is_grow = std::includes(nkeys.begin(), nkeys.end(),
|
||||
ckeys.begin(), ckeys.end());
|
||||
if (is_grow && nkeys.size() == ckeys.size()) is_grow = false;
|
||||
if (is_grow) { /* commit */ } else {
|
||||
if (sig != pending_signature_) {
|
||||
pending_signature_ = sig;
|
||||
pending_first_seen_ms_ = now;
|
||||
}
|
||||
if (now - pending_first_seen_ms_ < cfg_.shrink_hysteresis_ms) return;
|
||||
/* commit */
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 Fill свободных cells
|
||||
|
||||
После выбора template и cap'а по `nb_camera_cells`:
|
||||
|
||||
```cpp
|
||||
if (active.size() < cap) {
|
||||
std::vector<PoolEntry*> extras;
|
||||
pool_.for_each([&](PoolEntry& e) {
|
||||
if (!e.drawable()) return;
|
||||
if (e ∈ already_active) return;
|
||||
extras.push_back(&e);
|
||||
});
|
||||
std::sort(extras.begin(), extras.end(), priority_desc);
|
||||
while (active.size() < cap && !extras.empty()) {
|
||||
active.push_back(extras.front());
|
||||
extras.erase(extras.begin());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 Manual PTZ override
|
||||
|
||||
`Composer::set_layout(name)` в motion-mode:
|
||||
```cpp
|
||||
manual_override_until_ms_ = now + manual_override_duration_ms_; // default 60s
|
||||
```
|
||||
|
||||
`maybe_relayout()` пропускает работу пока `now < manual_override_until_ms_`.
|
||||
По истечении — `committed_signature_.clear()` → форс relayout.
|
||||
|
||||
## 8. extern "C" ABI shim
|
||||
|
||||
`composer_c_api.cpp` — тонкая обёртка:
|
||||
|
||||
```cpp
|
||||
extern "C" int cfc_composer_create(const cfc_composer_config_t* cfg,
|
||||
cfc_composer_t** out)
|
||||
{
|
||||
cfc::ComposerConfig cpp_cfg = {/* ... */};
|
||||
auto* comp = new (std::nothrow) cfc::Composer(cpp_cfg);
|
||||
if (!comp->ok()) { delete comp; return -1; }
|
||||
// manual_cells из cfg->cells → set_manual_cells()
|
||||
*out = reinterpret_cast<cfc_composer_t*>(comp);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
`cfc_composer_t` — opaque в C, `reinterpret_cast` к/от `cfc::Composer*`
|
||||
в shim'е.
|
||||
|
||||
`layouts_c_api.cpp` — аналогично для `cfc_layout_*`. Держит static
|
||||
кеш `vector<cfc_layout_t>` который пересинхронизируется с
|
||||
`cfc::current_templates()` при reload.
|
||||
|
||||
## 9. Build
|
||||
|
||||
### 9.1 CMake
|
||||
|
||||
```cmake
|
||||
project(cuframes-composer LANGUAGES C CXX CUDA)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
```
|
||||
|
||||
`COMPOSER_SOURCES_CPP` в `src/CMakeLists.txt` перечисляет все .cpp.
|
||||
|
||||
### 9.2 Host build (для CI / dev-машины Ubuntu 24.04)
|
||||
|
||||
```bash
|
||||
cd /home/claude/projects/cuframes-composer
|
||||
mkdir -p build && cd build
|
||||
cmake -DCMAKE_BUILD_TYPE=Release ..
|
||||
make -j$(nproc)
|
||||
```
|
||||
|
||||
### 9.3 Production build (Ubuntu 22.04 jammy)
|
||||
|
||||
См. [operations.md](operations.md). Кратко:
|
||||
```bash
|
||||
docker run --rm --gpus all \
|
||||
-v "$PWD":/src -w /src/build-jammy \
|
||||
cuframes-composer-builder:cached \
|
||||
bash -c 'cmake .. && make -j$(nproc)'
|
||||
```
|
||||
|
||||
## 10. Производительность
|
||||
|
||||
### 10.1 Гарантии zero-copy
|
||||
|
||||
- Один `CudaBuffer output_` на `Composer`, передаётся как `NV12Ref` всем
|
||||
cells/decorations
|
||||
- Cell не создаёт VRAM-allocation'ов на кадр (только `Decoration::draw`
|
||||
может делать FreeType-rebuild при `set_text` — это offline)
|
||||
- Layout::apply() пересоздаёт `vector<unique_ptr<Cell>>` только при
|
||||
смене template'а; обычно раз в N секунд
|
||||
|
||||
### 10.2 Virtual call overhead
|
||||
|
||||
`Cell::draw()` → `draw_content()` virtual call: 1 indirect call per cell
|
||||
per frame. При 25 fps × 16 cells = **400 calls/sec** — нерелевантно.
|
||||
|
||||
### 10.3 Hot path
|
||||
|
||||
CUDA kernels (`cugrid.cu`):
|
||||
- `fill_nv12` — 1 kernel launch на rect
|
||||
- `resize_nv12` — bilinear через 2 kernels (Y plane + UV plane)
|
||||
- `blit_rgba_nv12` — 1 kernel, RGBA → NV12 + alpha-blend
|
||||
|
||||
**Все Cell-операции конвертируются в N×fill_nv12 + 1×resize_nv12 +
|
||||
M×blit_rgba_nv12** — оптимизировать через batching пока не нужно
|
||||
(GPU не sticky на текущей нагрузке).
|
||||
|
||||
## 11. Где править распространённые задачи
|
||||
|
||||
| Хочу… | Файл |
|
||||
|---|---|
|
||||
| Изменить hysteresis | `composer.hpp` → `ComposerConfig::shrink_hysteresis_ms` |
|
||||
| Поменять цвет border'а cells | `layout.cpp` → `border_style.color_y/u/v` |
|
||||
| Поменять label-font | `mqtt_overlay.cpp` → `LabelDecoration` style + `LabelStyle::font_path` |
|
||||
| Добавить ZMQ-verb | `control.c::dispatch()` + новая `cmd_*` функция |
|
||||
| Поменять manual override длительность | `composer.hpp` → `manual_override_duration_ms_` |
|
||||
| Добавить новый MQTT-overlay anchor | `mqtt_overlay.cpp::reposition_overlay()` switch |
|
||||
| Поддержать color emoji | `overlay.c::text_rebuild_atlas()` — handle `FT_LOAD_COLOR` + bitmap BGRA → blit как RGBA |
|
||||
|
||||
## 12. Тестирование
|
||||
|
||||
### 12.1 Юнит (не реализован)
|
||||
|
||||
Запланировано: catch2-тесты для `pick_best_fit`, `collect_active`,
|
||||
hysteresis. Зависимость на CUDA — mock через `cfc_source_get_latest`
|
||||
шим.
|
||||
|
||||
### 12.2 Integration smoke (`examples/grid_record_cpp.cpp`)
|
||||
|
||||
Минимальный C++ smoke: init Composer, compose loop, dump NV12 → файл.
|
||||
Не использует NVENC (тестирует только композицию).
|
||||
|
||||
```bash
|
||||
build/examples/grid_record_cpp \
|
||||
--out=/tmp/dump.nv12 --frames=10 --width=1920 --height=1080 \
|
||||
--templates=docker/templates.json \
|
||||
--source=cam-parking,frigate=parking_overview,priority=100 \
|
||||
--motion-mode
|
||||
```
|
||||
|
||||
### 12.3 Production smoke
|
||||
|
||||
`docker logs cfc-grid | grep -E "loaded|template|motion|grow|shrink"` —
|
||||
живая телеметрия композитора.
|
||||
|
||||
## 13. Известные подводные камни
|
||||
|
||||
- **`cfc_overlay_t` не RAII** — managed через `cfc_composer_add_overlay` /
|
||||
`cfc_overlay_destroy` (Composer.cpp::~Composer уничтожает все).
|
||||
- **`pthread_mutex_t` в SourcePool** vs `std::mutex` — выбор `std::mutex`
|
||||
для C++ слоя, `pthread_mutex_t` для C-слоя (PoolEntry::last_motion_ms
|
||||
использует `std::atomic`).
|
||||
- **Compose loop НЕ blocking** на CUDA-операциях — `cudaStreamSynchronize`
|
||||
вызывается caller'ом (grid_record.c) перед NVENC.
|
||||
- **Frigate event может прийти ДО Layout::apply** — `motion_pulse`
|
||||
обновляет `last_motion_ms`, но cell для этой камеры ещё может не
|
||||
существовать. На следующем кадре `maybe_relayout` пересчитает.
|
||||
- **`dynamic_cast<CameraCell*>` в `Layout::find_camera_cell_rect`** —
|
||||
использует RTTI. Включён `-frtti` по умолчанию в g++/nvcc.
|
||||
|
||||
## 14. Workflow по изменению Phase-задач
|
||||
|
||||
1. Branch `feature/<phase>-<feature>` от `main`
|
||||
2. Реализация, host build PASS, jammy build PASS (см. operations.md)
|
||||
3. Bake image `gx/cuframes-composer:<phase>-stepN`
|
||||
4. Deploy на dev-target → smoke verify через VLC/logs
|
||||
5. Commit + push branch
|
||||
6. (Если нужно) — merge в `main` через `--no-ff` PR-style
|
||||
|
||||
Для multi-commit фазы: один WIP-merge commit на main с описанием
|
||||
ключевых изменений.
|
||||
@@ -0,0 +1,404 @@
|
||||
# cfc-grid — operations / deploy / troubleshooting
|
||||
|
||||
> Аудитория: тот кто билдит, деплоит, мониторит cfc-grid в проде.
|
||||
>
|
||||
> Если ты пользователь — см. [user.md](user.md). Если разработчик —
|
||||
> см. [developer.md](developer.md).
|
||||
|
||||
## 1. Production setup (R9-88.23)
|
||||
|
||||
### 1.1 Стек
|
||||
|
||||
```
|
||||
docker compose -f docker-compose.yml \
|
||||
-f cuda-grid/docker-compose.override.yml \
|
||||
-f cuframes-composer/docker-compose.override.yml \
|
||||
-f onvif/docker-compose.override.yml \
|
||||
up -d
|
||||
```
|
||||
|
||||
Файлы — в `localhost-infra/hosts/R9-88.23/docker/cctv/`.
|
||||
|
||||
| Сервис | Image | Назначение |
|
||||
|---|---|---|
|
||||
| `cuframes-ipc-anchor` | `gx/cuframes:0.4` | Shared VMM IPC anchor для cuframes |
|
||||
| `cuframes-pub-*` (parking/back_yard/front_yard/gate_lpr) | `gx/cuframes:0.4` | RTSP → cuframes per-camera publishers |
|
||||
| `cuda-grid-mediamtx` | `bluenviron/mediamtx` | RTSP/HLS/WebRTC gateway |
|
||||
| `cctv-mosquitto` | `eclipse-mosquitto` | MQTT broker (+bridge к 192.168.88.4) |
|
||||
| **`cfc-grid`** | `gx/cuframes-composer:0.11b-step1` | Композитор (главный сервис) |
|
||||
| `cfc-grid-ffmpeg` | `ffmpeg-vf-cuda-grid:phase4b-final` | H.264 pipe → RTSP push |
|
||||
| `cfc-grid-watchdog` | `gx/cuda-grid-watchdog:0.4` | Restart cfc-grid при stuck inboundBytes |
|
||||
| `cctv-onvif` | `gx/cctv-onvif:0.6` | ONVIF discovery + PTZ → ZMQ |
|
||||
| `cctv-frigate` | `ghcr.io/blakeblackshear/frigate` | Object detection → MQTT events |
|
||||
|
||||
### 1.2 Поток кадров
|
||||
|
||||
```
|
||||
cuframes-pub-X ──VMM──┐
|
||||
cuframes-pub-Y ──VMM──┼──→ cfc-grid (composer)
|
||||
cuframes-pub-Z ──VMM──┘ │
|
||||
│ H.264 NVENC
|
||||
↓ named pipe /tmp/cfc-pipe-dir/grid.h264
|
||||
cfc-grid-ffmpeg (re-mux)
|
||||
│ RTSP push
|
||||
↓
|
||||
cuda-grid-mediamtx
|
||||
rtsp://*/cfc-grid (TCP/UDP)
|
||||
http://*:8888/cfc-grid (HLS)
|
||||
http://*:8889/cfc-grid (WebRTC)
|
||||
```
|
||||
|
||||
### 1.3 Сети
|
||||
|
||||
- Внутренний docker network: `cctv`
|
||||
- Внешние порты на R9-88.23:
|
||||
- `554/tcp` — RTSP (mediamtx)
|
||||
- `8888/tcp` — HLS (mediamtx)
|
||||
- `8889/tcp` — WebRTC (mediamtx)
|
||||
- `5599/tcp` — ZMQ control plane composer'а
|
||||
- `8085/tcp` — ONVIF SOAP (cctv-onvif)
|
||||
- `3702/udp` — WS-Discovery multicast (cctv-onvif)
|
||||
|
||||
## 2. Build
|
||||
|
||||
### 2.1 Local host build (Ubuntu 24.04, dev машина)
|
||||
|
||||
```bash
|
||||
cd /home/claude/projects/cuframes-composer
|
||||
cmake -B build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
Артефакты в `build/src/libcuframes_composer.so` и `build/examples/grid_record`.
|
||||
|
||||
**ВАЖНО:** host'овый бинарь (Ubuntu 24.04, glibc 2.39, libavformat60)
|
||||
**несовместим** с runtime контейнером (Ubuntu 22.04 jammy, glibc 2.35,
|
||||
libavformat58). См. memory `incremental-ffmpeg-rebuild`.
|
||||
|
||||
### 2.2 Jammy build (для production image)
|
||||
|
||||
Использует кешированный builder-контейнер `cuframes-composer-builder:cached`
|
||||
(Ubuntu 22.04 + nvidia/cuda:12.4.1-devel + apt-deps):
|
||||
|
||||
```bash
|
||||
cd /home/claude/projects/cuframes-composer
|
||||
|
||||
# Если builder ещё не закеширован:
|
||||
docker image inspect cuframes-composer-builder:cached >/dev/null 2>&1 || {
|
||||
docker run -d --name cfc-builder-tmp \
|
||||
nvidia/cuda:12.4.1-devel-ubuntu22.04 sleep 3600
|
||||
docker exec cfc-builder-tmp bash -c '
|
||||
apt-get update -qq && apt-get install -y -qq --no-install-recommends \
|
||||
build-essential cmake git pkg-config \
|
||||
libpng-dev libfreetype-dev \
|
||||
libzmq3-dev libjson-c-dev libmosquitto-dev \
|
||||
libavformat-dev libavcodec-dev libavutil-dev'
|
||||
docker commit cfc-builder-tmp cuframes-composer-builder:cached
|
||||
docker rm -f cfc-builder-tmp
|
||||
}
|
||||
|
||||
# Сам build:
|
||||
docker run --rm --gpus all -v "$PWD":/src -w /src/build-jammy \
|
||||
cuframes-composer-builder:cached \
|
||||
bash -c 'cmake -DCMAKE_BUILD_TYPE=Release .. && make -j$(nproc)'
|
||||
```
|
||||
|
||||
Артефакты в `build-jammy/`.
|
||||
|
||||
### 2.3 Bake image (incremental — без `docker build`)
|
||||
|
||||
Не используем `docker build` (4GB CUDA pull при cache miss). Вместо:
|
||||
|
||||
```bash
|
||||
docker rmi gx/cuframes-composer:0.11b-step1 -f 2>/dev/null
|
||||
CID=$(docker create gx/cuframes-composer:0.10)
|
||||
docker cp build-jammy/examples/grid_record "$CID":/usr/local/bin/grid_record
|
||||
docker cp build-jammy/src/libcuframes_composer.so.0.1.0 \
|
||||
"$CID":/usr/lib/x86_64-linux-gnu/libcuframes_composer.so.0
|
||||
docker cp docker/templates.json "$CID":/opt/templates.json
|
||||
docker cp docker/mqtt_overlays.json "$CID":/opt/mqtt_overlays.json
|
||||
docker commit \
|
||||
--change 'ENTRYPOINT ["/usr/local/bin/grid_record"]' \
|
||||
--change 'CMD ["--help"]' \
|
||||
--change 'ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,video' \
|
||||
"$CID" gx/cuframes-composer:0.11b-step1
|
||||
docker rm "$CID"
|
||||
```
|
||||
|
||||
Использует базу `gx/cuframes-composer:0.10` (с уже установленными runtime
|
||||
deps) + накладывает свежие артефакты. Быстрее и без сетевого трафика.
|
||||
|
||||
### 2.4 Build ONVIF image
|
||||
|
||||
```bash
|
||||
cd hosts/R9-88.23/docker/cctv/onvif
|
||||
docker build -t gx/cctv-onvif:0.6 -f Dockerfile .
|
||||
```
|
||||
|
||||
Python image, лёгкий. Если меняешь `server.py` — rebuild image (тег
|
||||
поднимать) + правишь image в `docker-compose.override.yml`.
|
||||
|
||||
## 3. Deploy
|
||||
|
||||
### 3.1 Прод (R9-88.23)
|
||||
|
||||
```bash
|
||||
cd /home/claude/projects/localhost-infra/hosts/R9-88.23/docker/cctv
|
||||
docker compose -f docker-compose.yml \
|
||||
-f cuda-grid/docker-compose.override.yml \
|
||||
-f cuframes-composer/docker-compose.override.yml \
|
||||
-f onvif/docker-compose.override.yml \
|
||||
up -d cfc-grid
|
||||
```
|
||||
|
||||
Compose автоматически recreate'нет контейнер если image tag поменялся в
|
||||
`docker-compose.override.yml`.
|
||||
|
||||
### 3.2 Verify post-deploy
|
||||
|
||||
```bash
|
||||
# Logs композитора
|
||||
docker logs --tail 30 cfc-grid 2>&1 | grep -iE "loaded|template|pool|motion"
|
||||
|
||||
# Ожидаем что-то типа:
|
||||
# [cfc/loader] /opt/templates.json: loaded 9 templates
|
||||
# [cfc/composer] templates loaded: 9 (path='/opt/templates.json')
|
||||
# [cfc/composer] pool+ 'cam-parking' (frigate=parking_overview prio=100)
|
||||
# [cfc/composer] motion_mode=1 ttl=45000ms pool=4
|
||||
# [cfc/composer] grow → template='tpl_3' active=3
|
||||
```
|
||||
|
||||
### 3.3 Rollback
|
||||
|
||||
```bash
|
||||
sed -i 's|gx/cuframes-composer:0.11b-step1|gx/cuframes-composer:0.10|' \
|
||||
hosts/R9-88.23/docker/cctv/cuframes-composer/docker-compose.override.yml
|
||||
docker compose ... up -d cfc-grid
|
||||
```
|
||||
|
||||
## 4. Logs
|
||||
|
||||
### 4.1 Live tail
|
||||
|
||||
```bash
|
||||
docker logs -f --tail 50 cfc-grid
|
||||
docker logs -f --tail 30 cfc-grid-ffmpeg
|
||||
docker logs -f --tail 30 cuda-grid-mediamtx
|
||||
docker logs -f --tail 30 cctv-onvif
|
||||
docker logs -f --tail 30 cctv-frigate
|
||||
```
|
||||
|
||||
### 4.2 Telemetry pattern
|
||||
|
||||
| Маркер | Что значит |
|
||||
|---|---|
|
||||
| `[grid_record] N кадров, M IDR, X МБ за Y.0с (25.0 fps)` | Composer успешно encode'ит каждые ~50 кадров |
|
||||
| `[cfc/composer] grow → template='X'` | Применился новый template (расширение, мгновенно) |
|
||||
| `[cfc/composer] shrink → template='X'` | Применился новый template после hysteresis (сжатие) |
|
||||
| `[cfc/composer] manual override 'X' до +60000ms` | PTZ через ONVIF |
|
||||
| `[cfc/composer] manual override expired, возврат в motion-mode` | Auto-возврат после 60s |
|
||||
| `[cfc/mqtt-overlay/<id>] '<text>'` | MQTT-overlay получил/отрендерил новый text |
|
||||
| `[cfc/frigate] connected, subscribe 'frigate/events'` | Frigate-subscriber подключился |
|
||||
| `[cfc/temp] update: 'XX.X°C'` | (старый код, deprecated — теперь mqtt-overlay) |
|
||||
|
||||
### 4.3 Когда что-то сломалось
|
||||
|
||||
| Симптом | Где искать |
|
||||
|---|---|
|
||||
| `src active=0 stale=0 dead=4` | cuframes-pub-* контейнеры; проверь `docker ps` и сетевой доступ к камерам |
|
||||
| `overlay 0 draw failed` | `cfc_overlay_text_rebuild_atlas` — обычно невалидный шрифт или текст |
|
||||
| RTSP стрим не отдаёт | `cfc-grid-ffmpeg` логи; смотри §6.1 |
|
||||
| TV/ONVIF не находит | `cctv-onvif` логи; проверь multicast WS-Discovery в LAN |
|
||||
|
||||
## 5. Monitoring
|
||||
|
||||
### 5.1 MQTT health
|
||||
|
||||
`cfc-grid` публикует health в `cuda_grid/health/composer/cfc-grid`
|
||||
каждые ~10 секунд:
|
||||
|
||||
```json
|
||||
{
|
||||
"uptime_s": 3600,
|
||||
"frames_encoded": 90000,
|
||||
"fps_actual": 25.0,
|
||||
"bitrate_kbps": 6000,
|
||||
"src_active": 4,
|
||||
"src_stale": 0,
|
||||
"src_dead": 0,
|
||||
"idr_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
PW=$(grep '^COMPOSER_MQTT_PASSWORD=' \
|
||||
/home/claude/projects/localhost-infra/hosts/R9-88.23/docker/cctv/.env | cut -d= -f2)
|
||||
mosquitto_sub -h 192.168.88.23 -u composer -P "$PW" \
|
||||
-t 'cuda_grid/health/composer/cfc-grid' -v
|
||||
```
|
||||
|
||||
### 5.2 Watchdog
|
||||
|
||||
`cfc-grid-watchdog` — отдельный сервис, мониторит mediamtx
|
||||
`inboundBytes` для пути `cfc-grid`. Если **30 секунд молчания** —
|
||||
`docker restart cfc-grid`.
|
||||
|
||||
Логи watchdog'а:
|
||||
```bash
|
||||
docker logs --tail 30 cfc-grid-watchdog
|
||||
```
|
||||
|
||||
При срабатывании — публикует в `cuda_grid/health/watchdog/cfc-grid`.
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
### 6.1 RTSP не отдаёт / `cfc-grid-ffmpeg` в "Broken pipe"
|
||||
|
||||
**Симптом:** `docker logs cfc-grid-ffmpeg` показывает
|
||||
`[out#0/rtsp] Task finished with error code: -32 (Broken pipe)`.
|
||||
|
||||
**Причина:** `--intra-refresh` в composer'е (без IDR-burst'ов), mediamtx
|
||||
рвёт RTSP-publisher если не может отдать новому клиенту start-frame.
|
||||
|
||||
**Лечение:**
|
||||
- Полный restart pipeline:
|
||||
```bash
|
||||
docker compose ... restart cfc-grid-ffmpeg cfc-grid cuda-grid-mediamtx
|
||||
```
|
||||
- Если повторяется — отключить `--intra-refresh` в compose override
|
||||
(стоимость: IDR-bursts в bitrate, но стабильнее для downstream
|
||||
клиентов с frequent disconnect/reconnect)
|
||||
|
||||
### 6.2 ffmpeg не получает кадры от RTSP
|
||||
|
||||
**Симптом:** при `ffmpeg -i rtsp://192.168.88.23:554/cfc-grid -frames:v 1 out.jpg`
|
||||
зависает на 30+ секунд.
|
||||
|
||||
**Причина:** Composer пишет H.264 без regular IDR (intra-refresh). Новый
|
||||
RTSP-клиент ждёт keyframe для старта декодинга. ffmpeg в default
|
||||
конфигурации не ждёт достаточно долго.
|
||||
|
||||
**Workaround:**
|
||||
```bash
|
||||
ffmpeg -rtsp_transport tcp \
|
||||
-analyzeduration 10000000 -probesize 10000000 \
|
||||
-i rtsp://192.168.88.23:554/cfc-grid \
|
||||
-frames:v 1 -y out.jpg
|
||||
```
|
||||
|
||||
Или используй HLS:
|
||||
```bash
|
||||
ffmpeg -i http://192.168.88.23:8888/cfc-grid/index.m3u8 \
|
||||
-frames:v 1 -y out.jpg
|
||||
```
|
||||
|
||||
### 6.3 MQTT-overlay не обновляется
|
||||
|
||||
**Чек-лист:**
|
||||
|
||||
1. Бридж к HA broker (192.168.88.4) работает?
|
||||
```bash
|
||||
docker logs cctv-mosquitto 2>&1 | grep -i 'bridge'
|
||||
```
|
||||
Ищи `Connecting bridge ha-bridge` и подтверждение connect.
|
||||
|
||||
2. Нужный topic в bridge config?
|
||||
```bash
|
||||
docker exec cctv-mosquitto grep 'topic.*in 0' /mosquitto/config/mosquitto.conf
|
||||
```
|
||||
Если новый префикс — добавь `topic XXX/# in 0` и restart mosquitto.
|
||||
|
||||
3. Subscriber подключился?
|
||||
```bash
|
||||
docker logs cfc-grid 2>&1 | grep 'mqtt-overlay/<id>.*connected'
|
||||
```
|
||||
|
||||
4. Тестовый publish:
|
||||
```bash
|
||||
mosquitto_pub -h 192.168.88.4 -t '<твой topic>' -m 'test' -r
|
||||
```
|
||||
В логах composer'а должно появиться `[cfc/mqtt-overlay/<id>] 'test'`.
|
||||
|
||||
### 6.4 Motion-mode не переключает layout
|
||||
|
||||
**Чек-лист:**
|
||||
|
||||
1. Frigate шлёт events?
|
||||
```bash
|
||||
mosquitto_sub -h 192.168.88.23 -u composer -P "$PW" \
|
||||
-t 'frigate/events' -C 3
|
||||
```
|
||||
|
||||
2. Composer получает events?
|
||||
```bash
|
||||
docker logs cfc-grid 2>&1 | grep 'frigate.*started\|grow\|shrink'
|
||||
```
|
||||
|
||||
3. Camera-name матчится?
|
||||
`frigate=<имя>` в `--source` должно совпадать с `event.after.camera`.
|
||||
|
||||
4. Zone-filter не отсекает?
|
||||
Если `zones=A:B:C` в `--source` — посмотри в Frigate event
|
||||
`current_zones`. Если пусто или не пересекается — pulse отбрасывается.
|
||||
|
||||
5. TTL не истёк?
|
||||
Logs `motion_ttl=45000` (45 сек) — если события приходят реже —
|
||||
камера выпадает из active.
|
||||
|
||||
### 6.5 ONVIF PTZ presets пусты в TV
|
||||
|
||||
**Причина:** TV закешировал старый ответ `GetPresets` (Phase 9 имена).
|
||||
|
||||
**Лечение:** удалить и заново добавить камеру в TV-клиенте.
|
||||
|
||||
### 6.6 Templates загрузились но motion-mode не использует новый
|
||||
|
||||
Composer читает global registry `cfc::current_templates()` на каждом
|
||||
кадре — изменение через `cfc_layout_load_file` (ZMQ или CLI) должно
|
||||
быть подхвачено сразу. Если нет — проверь:
|
||||
|
||||
```bash
|
||||
echo '{"cmd":"list_layouts"}' | python3 -c "
|
||||
import zmq,json,sys
|
||||
s = zmq.Context().socket(zmq.REQ)
|
||||
s.connect('tcp://192.168.88.23:5599')
|
||||
s.send_json({'cmd':'list_layouts'})
|
||||
print(json.dumps(s.recv_json(), indent=2, ensure_ascii=False))"
|
||||
```
|
||||
|
||||
Поле `source` показывает текущий загруженный path. Если built-in (только
|
||||
`tpl_1` + `tpl_4`) — JSON не подгрузился (syntax error, кривой path).
|
||||
|
||||
## 7. Конфиги в репо
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| templates.json | `cuframes-composer/docker/templates.json` |
|
||||
| mqtt_overlays.json | `cuframes-composer/docker/mqtt_overlays.json` |
|
||||
| compose override | `localhost-infra/hosts/R9-88.23/docker/cctv/cuframes-composer/docker-compose.override.yml` |
|
||||
| ONVIF config | `localhost-infra/.../onvif/onvif.yaml` |
|
||||
| ONVIF server | `localhost-infra/.../onvif/server.py` |
|
||||
| Mosquitto config | `localhost-infra/.../cctv/mosquitto/config/mosquitto.conf` |
|
||||
| .env (passwords) | `localhost-infra/.../cctv/.env` (gitignored) |
|
||||
|
||||
После изменения compose override — `docker compose ... up -d cfc-grid`
|
||||
автоматически recreate'нет.
|
||||
|
||||
## 8. Известные ограничения / TODO
|
||||
|
||||
- **`--intra-refresh` ↔ RTSP-clients**: trade-off bitrate vs latency
|
||||
(см. §6.1)
|
||||
- **Watchdog только cfc-grid**: cfc-grid-ffmpeg в зомби-state не
|
||||
детектится напрямую; помогает только полный restart
|
||||
- **Hot-reload mqtt_overlays.json**: нет ZMQ verb'а
|
||||
- **MQTT-overlay per-broker config**: всё через один broker; для
|
||||
внешнего broker'а нужно расширить `MqttBrokerCfg` per-item
|
||||
|
||||
## 9. См. также
|
||||
|
||||
- [user.md](user.md) — настройка композитора
|
||||
- [developer.md](developer.md) — внутренности, добавление модулей
|
||||
- `memory/host-and-project.md` — общая инфра R9-88.23
|
||||
- `memory/project_cfc-grid-deployed.md` — deploy 1-го прода
|
||||
- `memory/project_cfc-grid-cpp-refactor.md` — Phase 11b refactor
|
||||
- `memory/incremental-ffmpeg-rebuild.md` — incremental docker recipe
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
# cfc-grid — руководство пользователя
|
||||
|
||||
> Аудитория: администратор инсталляции. Кто настраивает камеры, layout'ы,
|
||||
> overlay'и, смотрит RTSP-поток на TV или в браузере. Не правит C++ код.
|
||||
>
|
||||
> Если ты разработчик и хочешь добавить новый тип ячейки/декорации/виджета —
|
||||
> см. [developer.md](developer.md).
|
||||
|
||||
## 1. Что это
|
||||
|
||||
**cfc-grid** — CUDA-композитор, собирающий N камер в один RTSP-поток
|
||||
`rtsp://192.168.88.23:554/cfc-grid` (1920×1080, H.264, NVENC). Раскладка
|
||||
выбирается автоматически по движению (от Frigate) или вручную через
|
||||
ONVIF-presets с TV.
|
||||
|
||||
Параллельно с видеокадром поверх рисуются:
|
||||
|
||||
- **Borders** — серые рамки 2 px вокруг каждой ячейки
|
||||
- **Labels** — подпись `имя prio=N` в углу каждой камеры
|
||||
- **Detection boxes** — рамки объектов от Frigate, повторяющие позицию
|
||||
камеры при смене layout
|
||||
- **MQTT-overlays** — текстовые поля привязанные к топикам MQTT
|
||||
(температура, статусы, чаты)
|
||||
|
||||
## 2. Архитектура одной фразой
|
||||
|
||||
```
|
||||
cuframes-pub-* (на камеру)
|
||||
↓ shared VMM
|
||||
cfc-grid (composer) ── ZMQ control ──┐
|
||||
↓ pipe (H.264) │
|
||||
cfc-grid-ffmpeg (relay) ─→ mediamtx ─┴─→ TV / VLC / Frigate / ...
|
||||
↑
|
||||
ONVIF discovery от cctv-onvif
|
||||
```
|
||||
|
||||
Кадры через cuframes идут zero-copy (один VMM-буфер, разделяемый между
|
||||
publisher'ом и composer'ом). Композитор берёт NV12-поверхность,
|
||||
ресайзит/блитит в свой output, добавляет декорации, отдаёт NVENC,
|
||||
NVENC пишет H.264 в pipe, `cfc-grid-ffmpeg` транскодирует pipe → RTSP
|
||||
к mediamtx.
|
||||
|
||||
## 3. Motion-mode — основной режим работы
|
||||
|
||||
### 3.1 Что происходит
|
||||
|
||||
На каждом кадре композитор:
|
||||
|
||||
1. Берёт `last_motion_ms` для каждой камеры (обновляется из Frigate MQTT
|
||||
`frigate/events`)
|
||||
2. Считает «активными» те у кого `(now - last_motion_ms) < motion_ttl_ms`
|
||||
(по умолчанию **45 секунд**)
|
||||
3. Сортирует активных по `priority` (число; больше = главнее)
|
||||
4. Выбирает template из `templates.json` по правилу **best-fit**:
|
||||
минимальный template с `nb_camera_cells >= количество_активных`
|
||||
5. Если в template'е больше camera-cells, чем активных — лишние
|
||||
заполняются остальными drawable камерами из pool (по приоритету)
|
||||
6. Применяет **asymmetric hysteresis**: рост числа активных переключает
|
||||
layout мгновенно, уменьшение — ждёт 3 секунды (чтобы не мелькало)
|
||||
|
||||
### 3.2 Что значит «drawable»
|
||||
|
||||
Камера **исключается** из pool если её `cfc_source_state_t` =
|
||||
`CONNECTING`, `DISCONNECTED` или `DEAD` (cuframes publisher молчит
|
||||
дольше `dead_threshold_ms`, по умолчанию 5 секунд).
|
||||
|
||||
`STALE` (кадры приходят редко) — считается, рисуется последний доступный
|
||||
кадр.
|
||||
|
||||
### 3.3 Manual override через PTZ
|
||||
|
||||
В TV в ONVIF PTZ-presets отображаются имена templates (`tpl_1, tpl_3,
|
||||
tpl_4, ..., tpl_16`). Нажатие `GotoPreset` или movement-кнопок:
|
||||
|
||||
- Применяет выбранный layout мгновенно
|
||||
- **Замораживает** motion-mode на 60 секунд
|
||||
- По истечении — возвращается в auto-режим
|
||||
|
||||
ContinuousMove (стрелки): pan/tilt циклируют по списку presets, zoom-in
|
||||
=`tpl_1` (full screen), zoom-out=`tpl_16` (4×4 grid).
|
||||
|
||||
## 4. templates.json — раскладка экрана
|
||||
|
||||
### 4.1 Схема
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"grid_cols": 8,
|
||||
"grid_rows": 8,
|
||||
"templates": [
|
||||
{
|
||||
"name": "tpl_N",
|
||||
"_desc": "Описание",
|
||||
"priority": 0,
|
||||
"cells": [
|
||||
{
|
||||
"col": 0, "row": 0,
|
||||
"cs": 4, "rs": 4,
|
||||
"role": "camera",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"col": 6, "row": 0,
|
||||
"cs": 2, "rs": 6,
|
||||
"role": "widget",
|
||||
"widget": "temp_chart"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Грид: 8×8 микроячеек** (240×135 px каждая = 16:9 на output 1920×1080).
|
||||
Любой квадрат N×N микроячеек тоже 16:9.
|
||||
|
||||
| Поле | Значение |
|
||||
|---|---|
|
||||
| `col`, `row` | Top-left угол cell в микроячейках (0..7) |
|
||||
| `cs`, `rs` | Размер в микроячейках |
|
||||
| `role` | `camera` либо `widget` |
|
||||
| `order` | Для `camera`: порядок placement'а активных (0 = главная, обычно крупнейшая cell) |
|
||||
| `widget` | Для `widget`: имя placeholder'а (текст подписи) |
|
||||
|
||||
### 4.2 Best-fit selection
|
||||
|
||||
Композитор выбирает template для текущего числа активных:
|
||||
|
||||
```
|
||||
candidates = [t for t in templates if t.nb_camera_cells >= n_active]
|
||||
pick = min(candidates, key=lambda t: t.nb_camera_cells - n_active)
|
||||
# При равенстве: больший priority побеждает
|
||||
```
|
||||
|
||||
Если активных больше чем cell'ов в самом большом template — берётся
|
||||
самый большой, лишние камеры обрезаются (низший priority вылетает).
|
||||
|
||||
### 4.3 Встроенные templates
|
||||
|
||||
По умолчанию 9 templates в `/opt/templates.json`:
|
||||
|
||||
| Имя | Cells | Описание |
|
||||
|---|---|---|
|
||||
| `tpl_1` | 1 cam | Одна камера во весь экран |
|
||||
| `tpl_3` | 3 cam + 2 widgets | Главная 1440×810 слева + 2 превью справа + 2 widget |
|
||||
| `tpl_4` | 4 cam | Quad 2×2, 960×540 каждая |
|
||||
| `tpl_5` | 5 cam + 1 widget | Главная + 4 превью справа стопкой + widget снизу |
|
||||
| `tpl_6` | 6 cam + 1 widget | Главная + 3 правые + 2 нижние + widget |
|
||||
| `tpl_7` | 7 cam + 1 widget | Главная + 3 правые + 3 нижние + widget |
|
||||
| `tpl_8` | 8 cam (1+3+4) | Главная + 3 правые + 4 в нижней строке |
|
||||
| `tpl_9` | 9 cam + 2 widgets | 3×3 главных + widget справа + widget снизу |
|
||||
| `tpl_16` | 16 cam | 4×4 grid, 480×270 каждая |
|
||||
|
||||
Подробности — см. `docker/templates.json` в репо.
|
||||
|
||||
### 4.4 Как добавить свой template
|
||||
|
||||
1. Открыть `docker/templates.json` (или подмонтированный override)
|
||||
2. Добавить блок в `"templates": [...]` по схеме выше
|
||||
3. Перезапустить cfc-grid (либо `docker exec cfc-grid sh -c 'kill -HUP 1'`
|
||||
когда добавим hot-reload в Phase 12), либо вызвать ZMQ:
|
||||
|
||||
```bash
|
||||
mosquitto_pub -h 192.168.88.23 -p 5599 ... # пока нет CLI, см. operations.md
|
||||
```
|
||||
|
||||
### 4.5 Координатная математика
|
||||
|
||||
Каждая микроячейка = `1920/8 = 240 px` ширина, `1080/8 = 135 px` высота
|
||||
(aspect 16:9). Cell `{col=2, row=4, cs=4, rs=2}`:
|
||||
|
||||
- pixel x = `2 * 240 = 480`
|
||||
- pixel y = `4 * 135 = 540`
|
||||
- pixel w = `4 * 240 = 960`
|
||||
- pixel h = `2 * 135 = 270`
|
||||
|
||||
Aspect cell = `cs/rs * 16/9`. Для 16:9 cell'ов **cs == rs**.
|
||||
|
||||
## 5. mqtt_overlays.json — text overlay'и из MQTT
|
||||
|
||||
### 5.1 Схема
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"overlays": [
|
||||
{
|
||||
"id": "temp_outside",
|
||||
"topic": "zigbee2mqtt/Температура на улице",
|
||||
"json_field": "temperature",
|
||||
"format": "%+.1f°C",
|
||||
"anchor": "right-bottom",
|
||||
"margin_x": 32, "margin_y": 24,
|
||||
"pixel_size": 32,
|
||||
"color": [255, 255, 255],
|
||||
"alpha": 230,
|
||||
"bg_alpha": 160,
|
||||
"bg_y": 16, "bg_u": 128, "bg_v": 128,
|
||||
"bg_pad": 10,
|
||||
"placeholder": "—",
|
||||
"font_path": "/fonts/DejaVuSans-Bold.ttf"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| `id` | Уникальный идентификатор overlay'я (используется ZMQ для lookup'а) |
|
||||
| `topic` | MQTT topic для subscribe (через `cctv-mosquitto`) |
|
||||
| `json_field` | Если payload JSON — имя поля для извлечения значения; **пусто = raw payload как строка** |
|
||||
| `format` | `printf`-стиль для отформатированного значения (например `"%+.1f°C"`, `"%s"`) |
|
||||
| `anchor` | Якорь позиционирования: `right-bottom`, `right-top`, `left-bottom`, `left-top`, `center` |
|
||||
| `margin_x`, `margin_y` | Отступ от ближайшего края экрана (px) |
|
||||
| `pixel_size` | Размер шрифта в пикселях |
|
||||
| `color` | RGB цвет текста |
|
||||
| `alpha` | Общая непрозрачность текста (0..255) |
|
||||
| `bg_alpha` | Непрозрачность подложки (0..255); **0 = без фона** |
|
||||
| `bg_y`, `bg_u`, `bg_v` | BT.709 limited-range цвет подложки (Y=16..235, UV=16..240); default чёрный |
|
||||
| `bg_pad` | Отступ подложки вокруг текста (px) |
|
||||
| `placeholder` | Что показывать пока не пришло MQTT-сообщение; пусто = "—" |
|
||||
| `font_path` | Путь к шрифту (.ttf/.otf) в контейнере |
|
||||
|
||||
### 5.2 Поддерживаемые символы
|
||||
|
||||
Шрифт `DejaVuSans-Bold.ttf` — стандартный из пакета `fonts-dejavu`.
|
||||
**Покрывает Basic Multilingual Plane** (latin, cyrillic, базовые
|
||||
символы), включая:
|
||||
|
||||
- `❯` (U+276F), `✎` (U+270E), `➤` (U+27A4), `→` (U+2192)
|
||||
- `★` (U+2605), `▶` (U+25B6), `✉` (U+2709)
|
||||
|
||||
**Emoji из Supplementary Multilingual Plane (>U+10000)** — например
|
||||
`🗣` (U+1F5E3), `🤖` (U+1F916), `💬` (U+1F4AC) — **не рендерятся**:
|
||||
шрифт не содержит таких глифов. Рисуется placeholder-квадрат.
|
||||
|
||||
Чтобы добавить color emoji — нужно подключить Noto Color Emoji и
|
||||
расширить рендерер для COLR/CPAL/SBIX (см. developer.md).
|
||||
|
||||
### 5.3 Примеры
|
||||
|
||||
**Только число**: payload = `"23.5"` (raw), `json_field: ""`, `format: "%s°"`
|
||||
|
||||
**JSON с полем**: payload = `{"temperature": 23.5, "humidity": 45}`,
|
||||
`json_field: "temperature"`, `format: "%+.1f°C"`
|
||||
|
||||
**Несколько overlay'ев** в правом-верхнем углу столбиком: первый с
|
||||
`margin_y: 24`, второй с `margin_y: 72`, третий с `margin_y: 120`.
|
||||
|
||||
### 5.4 Как добавить
|
||||
|
||||
1. Открыть `docker/mqtt_overlays.json` (либо подмонтированный override)
|
||||
2. Добавить блок в массив `"overlays": [...]`
|
||||
3. Перезапустить cfc-grid
|
||||
|
||||
Hot-reload через ZMQ — Phase 12 (`reload_overlays` verb).
|
||||
|
||||
## 6. CLI флаги composer'а
|
||||
|
||||
В compose override `docker/cctv/cuframes-composer/docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- "--out=/out/grid.h264" # named pipe для ffmpeg-relay
|
||||
- "--fps=25"
|
||||
- "--bitrate=6000" # kbps
|
||||
- "--width=1920"
|
||||
- "--height=1080"
|
||||
- "--intra-refresh" # вместо IDR-burst'ов (low-latency)
|
||||
- "--control=tcp://0.0.0.0:5599" # ZMQ control plane
|
||||
- "--mqtt=cctv-mosquitto:1883" # MQTT для health-публикации
|
||||
- "--mqtt-instance=cfc-grid"
|
||||
- "--mqtt-user=composer"
|
||||
- "--mqtt-pass=${COMPOSER_MQTT_PASSWORD}"
|
||||
|
||||
# Источники (камеры) — повторяемое
|
||||
- "--source=cam-parking,frigate=parking_overview,priority=100,zones=parking_zone:canopy:private_area"
|
||||
- "--source=cam-back_yard,frigate=back_yard,priority=70"
|
||||
# ...
|
||||
|
||||
- "--motion-mode" # включить auto-layout
|
||||
- "--motion-ttl=45000" # ms
|
||||
|
||||
- "--templates=/opt/templates.json"
|
||||
- "--mqtt-overlays=/opt/mqtt_overlays.json"
|
||||
|
||||
# Frigate motion-driver и detection boxes
|
||||
- "--frigate-mqtt=cctv-mosquitto:1883"
|
||||
- "--frigate-topic=frigate/events"
|
||||
- "--detection-cell=parking,parking_overview,0,0,960,540,640,480,parking_zone:canopy:private_area"
|
||||
```
|
||||
|
||||
### 6.1 `--source` синтаксис
|
||||
|
||||
```
|
||||
--source=<cuframes_key>,frigate=<camera>,priority=<N>[,zones=<z1>:<z2>:...]
|
||||
```
|
||||
|
||||
- `cuframes_key` — имя cuframes-publisher'а (например `cam-parking`)
|
||||
- `frigate=NAME` — имя камеры в Frigate (для матчинга motion-событий)
|
||||
- `priority=N` — целое, больше = главнее
|
||||
- `zones=...` — опциональный whitelist Frigate zones; motion засчитывается
|
||||
только если `event.current_zones` пересекается со списком (отсев
|
||||
street-флуда)
|
||||
|
||||
### 6.2 `--detection-cell` синтаксис
|
||||
|
||||
```
|
||||
--detection-cell=<key>,<frigate_camera>,<x>,<y>,<w>,<h>,<detect_w>,<detect_h>[,zones]
|
||||
```
|
||||
|
||||
- `key` — произвольный идентификатор overlay'я для логов
|
||||
- `frigate_camera` — имя в Frigate (для матчинга event.camera)
|
||||
- `x,y,w,h` — initial geometry (composer пересчитает динамически)
|
||||
- `detect_w,detect_h` — разрешение детектора Frigate (например 640×480)
|
||||
- `zones` — whitelist для bbox
|
||||
|
||||
## 7. ZMQ control plane
|
||||
|
||||
Default endpoint: `tcp://192.168.88.23:5599`. Все verb'ы — JSON request/reply.
|
||||
|
||||
### 7.1 Список verbs
|
||||
|
||||
| Команда | Параметры | Что делает |
|
||||
|---|---|---|
|
||||
| `ping` | — | Health-check |
|
||||
| `health` | — | `{total, active, stale, dead}` по pool'у |
|
||||
| `set_text` | `id, text, r, g, b, x, y, visible` | Обновить текстовый overlay (для CLI `--text=...`) |
|
||||
| `set_visible` | `id, visible` | Скрыть/показать overlay |
|
||||
| `list_overlays` | — | Список overlay'ев |
|
||||
| `set_layout` | `name` | Применить named template (manual override на 60s в motion-mode) |
|
||||
| `list_layouts` | — | Список доступных templates с cells |
|
||||
| `get_layout` | — | Имя текущего template'а |
|
||||
| `set_motion_mode` | `on, ttl_ms` | Включить/выключить motion-режим |
|
||||
| `get_motion_mode` | — | Состояние motion-mode |
|
||||
| `get_template` | `name` | Полный JSON template'а |
|
||||
| `reload_templates` | `path?` | Перезагрузить templates из файла (default — последний путь) |
|
||||
|
||||
### 7.2 Пример
|
||||
|
||||
```bash
|
||||
# Python
|
||||
python3 <<EOF
|
||||
import zmq, json
|
||||
s = zmq.Context().socket(zmq.REQ)
|
||||
s.connect("tcp://192.168.88.23:5599")
|
||||
s.send_json({"cmd": "list_layouts"})
|
||||
print(json.dumps(s.recv_json(), indent=2, ensure_ascii=False))
|
||||
EOF
|
||||
```
|
||||
|
||||
```bash
|
||||
# Force layout
|
||||
echo '{"cmd":"set_layout","name":"tpl_4"}' | \
|
||||
nc -q1 192.168.88.23 5599 # ! REP socket требует ZMQ framing, не голый TCP
|
||||
```
|
||||
|
||||
Голый `nc` **не работает** — REP socket ожидает ZMQ wire-protocol. Используй
|
||||
`zmq` Python/Go/JS либо `mosquitto_pub` через RPC-bridge (Phase 12).
|
||||
|
||||
## 8. ONVIF и PTZ
|
||||
|
||||
Сервис `cctv-onvif` биндится на host network, отвечает на WS-Discovery
|
||||
multicast (239.255.255.250:3702) и SOAP-запросы по HTTP `:8085`.
|
||||
|
||||
### 8.1 Добавление в TV
|
||||
|
||||
В клиенте (TV / IP-CamViewer / Synology / Frigate):
|
||||
|
||||
- ONVIF host: `192.168.88.23`
|
||||
- Port: `8085`
|
||||
- User / Password: пусто (auth не настроен)
|
||||
|
||||
WS-Discovery в LAN 192.168.88.0/24 найдёт устройство `cfc-grid (Goldix)`.
|
||||
RTSP-URL автоматически — `rtsp://192.168.88.23:554/cfc-grid`.
|
||||
|
||||
### 8.2 PTZ presets
|
||||
|
||||
Список (`GetPresets`): `tpl_1, tpl_3, tpl_4, tpl_5, tpl_6, tpl_7, tpl_8,
|
||||
tpl_9, tpl_16`.
|
||||
|
||||
GotoPreset(name) → composer применяет template + замораживает motion-mode
|
||||
на 60 секунд → auto-возврат.
|
||||
|
||||
### 8.3 PTZ movement (ContinuousMove)
|
||||
|
||||
| Команда | Действие |
|
||||
|---|---|
|
||||
| Pan right / Tilt down | Следующий template в списке |
|
||||
| Pan left / Tilt up | Предыдущий |
|
||||
| Zoom in (+) | `tpl_1` (full screen) |
|
||||
| Zoom out (−) | `tpl_16` (4×4 grid) |
|
||||
|
||||
## 9. Где смотреть RTSP
|
||||
|
||||
| Способ | URL |
|
||||
|---|---|
|
||||
| VLC / mpv / ffplay | `rtsp://192.168.88.23:554/cfc-grid` |
|
||||
| Браузер (HLS) | `http://192.168.88.23:8888/cfc-grid` |
|
||||
| WebRTC | `http://192.168.88.23:8889/cfc-grid` |
|
||||
| OBS / FFmpeg input | `rtsp://192.168.88.23:554/cfc-grid` |
|
||||
| ONVIF клиенты | через WS-Discovery (см. §8) |
|
||||
|
||||
## 10. Известные ограничения
|
||||
|
||||
- **Color emoji не рендерятся** (нужен Noto Color Emoji + COLR/CPAL
|
||||
поддержка в text renderer — Phase 12)
|
||||
- **Hot-reload `mqtt_overlays.json`** — нет ZMQ verb'а, нужен restart
|
||||
cfc-grid
|
||||
- **Per-overlay broker** — все MQTT-overlay'и используют общий
|
||||
broker (тот что задан как `--mqtt`); подписку на сторонний broker
|
||||
отдельно — нет
|
||||
- **Widget rendering** — placeholder (тёмный rect + label), реальные
|
||||
виджеты (graph, chat) — Phase 12+
|
||||
- **HA Assist в MQTT** — архитектурное ограничение HA (см. operations.md
|
||||
§Troubleshooting)
|
||||
|
||||
## 11. FAQ
|
||||
|
||||
**В: На TV вижу старые имена layouts (`quad`, `dual_horizontal`).
|
||||
Что делать?**
|
||||
|
||||
О: TV закешировал ONVIF-presets. В клиенте удали камеру и добавь
|
||||
заново — он перечитает `GetPresets` с актуальными именами.
|
||||
|
||||
**В: Камера парковки в DEAD, но в logs показывает active=3.
|
||||
Почему?**
|
||||
|
||||
О: `cfc_composer_get_health` показывает **pool-wide** state, а
|
||||
motion-active считает по `last_motion_ms` независимо от source state.
|
||||
DEAD исключается на этапе `is_camera_drawable()` в `compose_motion_relayout`.
|
||||
|
||||
**В: PTZ нажал на TV, рамка переключилась, через минуту вернулась
|
||||
обратно.**
|
||||
|
||||
О: Это by design — `set_layout` в motion-mode замораживает auto на
|
||||
60 секунд (`manual_override_duration_ms`). Чтобы зафиксировать template
|
||||
надолго — выключи motion-mode целиком через ZMQ:
|
||||
|
||||
```json
|
||||
{"cmd": "set_motion_mode", "on": 0}
|
||||
```
|
||||
|
||||
**В: Хочу подложку у text overlay'я разного цвета.**
|
||||
|
||||
О: Поля `bg_y/bg_u/bg_v` в JSON принимают BT.709 limited-range. Чтобы
|
||||
получить красный — Y≈80, U≈90, V≈240. Для голубого — Y≈170, U≈170, V≈100.
|
||||
Калькулятор: https://www.rapidtables.com/convert/color/rgb-to-yuv.html
|
||||
(использовать BT.709 limited).
|
||||
|
||||
**В: При motion на 5 камерах layout не появляется, остаётся quad.**
|
||||
|
||||
О: Проверь `docker logs cfc-grid | grep "loaded N templates"` — там
|
||||
должно быть ≥5 (есть `tpl_5..tpl_8`, `tpl_9`, `tpl_16`). Если нет —
|
||||
templates.json не подгрузился (проверь syntax через `jq` либо
|
||||
`python3 -m json.tool`).
|
||||
|
||||
**В: Frigate-bbox рисуется не на той камере.**
|
||||
|
||||
О: Проверь `--detection-cell` — там должен быть `frigate_camera`
|
||||
который совпадает с `event.after.camera`. Composer связывает
|
||||
detbox-overlay с pool-entry по `frigate_camera` (см.
|
||||
`cfc_composer::pool::by_frigate_camera`).
|
||||
|
||||
## 12. Куда дальше
|
||||
|
||||
- [developer.md](developer.md) — внутреннее устройство, расширение
|
||||
- [operations.md](operations.md) — build, deploy, troubleshooting
|
||||
- README репо: краткий overview
|
||||
@@ -15,3 +15,9 @@ target_include_directories(simple_record PRIVATE ${CMAKE_SOURCE_DIR}/include)
|
||||
add_executable(grid_record grid_record.c)
|
||||
target_link_libraries(grid_record PRIVATE cuframes_composer_static)
|
||||
target_include_directories(grid_record PRIVATE ${CMAKE_SOURCE_DIR}/include)
|
||||
|
||||
# Phase 11b — C++ ООП-гипотеза. Использует cfc::Composer напрямую (без C ABI shim).
|
||||
add_executable(grid_record_cpp grid_record_cpp.cpp)
|
||||
target_link_libraries(grid_record_cpp PRIVATE cuframes_composer_static)
|
||||
target_include_directories(grid_record_cpp PRIVATE ${CMAKE_SOURCE_DIR}/include)
|
||||
target_compile_features(grid_record_cpp PRIVATE cxx_std_17)
|
||||
|
||||
+111
-1
@@ -127,6 +127,13 @@ int main(int argc, char **argv)
|
||||
const char *frigate_mqtt_host = NULL;
|
||||
int frigate_mqtt_port = 1883;
|
||||
const char *frigate_topic = "frigate/events";
|
||||
/* YOLO-World subscriber (Phase 3 yolo-world-detector) — параллельный
|
||||
* detection-overlay поток. Использует те же detection-cells что и
|
||||
* Frigate, но рендерит bbox magenta цветом. По умолчанию выключен. */
|
||||
const char *yw_mqtt_host = NULL;
|
||||
int yw_mqtt_port = 1883;
|
||||
const char *yw_topic = "yoloworld/events";
|
||||
const char *mqtt_overlays_path = NULL; /* JSON-конфиг MQTT-driven text overlays */
|
||||
const char *initial_layout = NULL; /* --layout NAME → set_layout после init */
|
||||
int motion_mode = 0; /* --motion-mode */
|
||||
int motion_ttl = 45000; /* --motion-ttl ms */
|
||||
@@ -173,17 +180,20 @@ int main(int argc, char **argv)
|
||||
{"audio-source", required_argument, 0, 'A'}, /* RTSP audio URL */
|
||||
{"frigate-mqtt", required_argument, 0, 'G'}, /* host[:port] */
|
||||
{"frigate-topic", required_argument, 0, 'T'},
|
||||
{"yw-mqtt", required_argument, 0, 'Y'}, /* host[:port] для yolo-world detector */
|
||||
{"yw-topic", required_argument, 0, 'Q'},
|
||||
{"detection-cell", required_argument, 0, 'D'},
|
||||
{"layout", required_argument, 0, 'L'}, /* named layout (quad, single, ...) */
|
||||
{"source", required_argument, 0, 'S'}, /* pool source: key,frigate=...,priority=N */
|
||||
{"motion-mode", no_argument, 0, 'm'}, /* enable motion-driven auto layout */
|
||||
{"motion-ttl", required_argument, 0, 'k'}, /* TTL ms (default 45000) */
|
||||
{"templates", required_argument, 0, 'z'}, /* path to templates.json */
|
||||
{"mqtt-overlays", required_argument, 0, 'x'}, /* path to MQTT overlays JSON */
|
||||
{0, 0, 0, 0},
|
||||
};
|
||||
const char *templates_path = NULL;
|
||||
int c;
|
||||
while ((c = getopt_long(argc, argv, "o:c:f:b:W:H:s:r:i:t:C:M:I:U:P:RF:A:G:T:D:L:S:mk:z:", opts, NULL)) != -1) {
|
||||
while ((c = getopt_long(argc, argv, "o:c:f:b:W:H:s:r:i:t:C:M:I:U:P:RF:A:G:T:Y:Q:D:L:S:mk:z:x:", opts, NULL)) != -1) {
|
||||
switch (c) {
|
||||
case 'o': out_path = optarg; break;
|
||||
case 'c':
|
||||
@@ -237,10 +247,26 @@ int main(int argc, char **argv)
|
||||
break;
|
||||
}
|
||||
case 'T': frigate_topic = optarg; break;
|
||||
case 'Y': {
|
||||
yw_mqtt_host = optarg;
|
||||
const char *colon = strchr(optarg, ':');
|
||||
if (colon) {
|
||||
static char yw_host_buf[64];
|
||||
int n = colon - optarg;
|
||||
if (n >= (int)sizeof(yw_host_buf)) n = sizeof(yw_host_buf) - 1;
|
||||
memcpy(yw_host_buf, optarg, n);
|
||||
yw_host_buf[n] = '\0';
|
||||
yw_mqtt_host = yw_host_buf;
|
||||
yw_mqtt_port = atoi(colon + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Q': yw_topic = optarg; break;
|
||||
case 'L': initial_layout = optarg; break;
|
||||
case 'm': motion_mode = 1; break;
|
||||
case 'k': motion_ttl = atoi(optarg); break;
|
||||
case 'z': templates_path = optarg; break;
|
||||
case 'x': mqtt_overlays_path = optarg; break;
|
||||
case 'S': {
|
||||
if (num_sources >= 32) {
|
||||
fprintf(stderr, "max 32 sources\n"); return 1;
|
||||
@@ -446,6 +472,21 @@ int main(int argc, char **argv)
|
||||
cfc_composer_set_motion_mode(comp, 1, motion_ttl);
|
||||
}
|
||||
|
||||
/* Глобальные MQTT-driven overlays (температура и т.п.) — JSON-конфиг.
|
||||
* Каждая запись = MQTT subscribe + persistent text overlay. См.
|
||||
* include/cuframes_composer/cpp/mqtt_overlay.hpp для schema. */
|
||||
if (mqtt_overlays_path) {
|
||||
extern int cfc_mqtt_overlays_load(cfc_composer_t *, const char *,
|
||||
const char *, int,
|
||||
const char *, const char *,
|
||||
int, int);
|
||||
int n = cfc_mqtt_overlays_load(comp, mqtt_overlays_path,
|
||||
mqtt_host ? mqtt_host : "cctv-mosquitto", mqtt_port,
|
||||
mqtt_user, mqtt_pass,
|
||||
out_w, out_h);
|
||||
fprintf(stderr, "[grid_record] mqtt overlays loaded: %d\n", n);
|
||||
}
|
||||
|
||||
/* --layout NAME → applies named layout поверх --cell координат. Удобно
|
||||
* как default для ONVIF PTZ-управляемого composer'а (старт в quad,
|
||||
* далее set_layout через ZMQ). В motion-mode не работает (relayout
|
||||
@@ -529,6 +570,11 @@ int main(int argc, char **argv)
|
||||
.stale_ms = 8000,
|
||||
.required_zones = detcells[i].num_zones ? detcells[i].zone_ptrs : NULL,
|
||||
.required_zones_count = detcells[i].num_zones,
|
||||
/* Label+score pill — белым текстом на полупрозрачном зелёном
|
||||
* фоне (color_y/u/v). Шрифт DejaVu mounted из /fonts (см. compose). */
|
||||
.font_path = "/fonts/DejaVuSans-Bold.ttf",
|
||||
.font_size = 16,
|
||||
.label_bg_alpha = 200,
|
||||
};
|
||||
if (cfc_overlay_create_detection_boxes(&dc, &detbox_overlays[i]) != 0) {
|
||||
fprintf(stderr, "[grid_record] detbox create failed для '%s'\n",
|
||||
@@ -546,6 +592,42 @@ int main(int argc, char **argv)
|
||||
fprintf(stderr, "\n");
|
||||
}
|
||||
|
||||
/* YOLO-World detection-box overlays — параллельный набор для второго
|
||||
* subscriber'а. Magenta цвет (BT.709 limited Y=105 U=212 V=234). Те же
|
||||
* detection-cells (camera/zones), но bbox рисуется magenta. На один
|
||||
* frame можно увидеть зелёный bbox от Frigate И magenta от YOLO-World
|
||||
* — если оба детектят. yolo-world-detector публикует в MQTT topic
|
||||
* yoloworld/events/<camera> с Frigate-compat envelope. */
|
||||
cfc_overlay_t *yw_detbox_overlays[MAX_CELLS] = { 0 };
|
||||
if (yw_mqtt_host) {
|
||||
for (int i = 0; i < num_detcells; i++) {
|
||||
cfc_overlay_detbox_config_t yc = {
|
||||
.camera_key = detcells[i].camera,
|
||||
.detect_w = detcells[i].detect_w,
|
||||
.detect_h = detcells[i].detect_h,
|
||||
.cell_x = detcells[i].dx, .cell_y = detcells[i].dy,
|
||||
.cell_w = detcells[i].dw, .cell_h = detcells[i].dh,
|
||||
.thickness = 6,
|
||||
.color_y = 105, .color_u = 212, .color_v = 234, /* magenta */
|
||||
.alpha = 240,
|
||||
.stale_ms = 8000,
|
||||
.required_zones = detcells[i].num_zones ? detcells[i].zone_ptrs : NULL,
|
||||
.required_zones_count = detcells[i].num_zones,
|
||||
.font_path = "/fonts/DejaVuSans-Bold.ttf",
|
||||
.font_size = 16,
|
||||
.label_bg_alpha = 200,
|
||||
};
|
||||
if (cfc_overlay_create_detection_boxes(&yc, &yw_detbox_overlays[i]) != 0) {
|
||||
fprintf(stderr, "[grid_record] yw detbox create failed для '%s'\n",
|
||||
detcells[i].camera);
|
||||
continue;
|
||||
}
|
||||
cfc_composer_add_overlay(comp, yw_detbox_overlays[i]);
|
||||
fprintf(stderr, "[grid_record] yw detbox '%s' → cell %s (magenta)\n",
|
||||
detcells[i].camera, detcells[i].key);
|
||||
}
|
||||
}
|
||||
|
||||
/* Frigate MQTT subscriber: запускаем если есть detection-cells
|
||||
* (overlay'ные bbox'ы) ИЛИ motion-mode (auto-layout drivers). */
|
||||
cfc_frigate_mqtt_t *frigate = NULL;
|
||||
@@ -568,6 +650,33 @@ int main(int argc, char **argv)
|
||||
}
|
||||
}
|
||||
|
||||
/* YOLO-World MQTT subscriber — параллельный поток detection-events с
|
||||
* yoloworld/events/<camera>. Использует тот же envelope как Frigate
|
||||
* (cfc_frigate_mqtt парсер совместим), но рендерит на yw_detbox_overlays
|
||||
* (magenta). motion-pulse'ы НЕ шлёт (composer NULL), композитор
|
||||
* управляется только Frigate motion-pulse'ами. */
|
||||
cfc_frigate_mqtt_t *yw_mqtt = NULL;
|
||||
if (yw_mqtt_host && num_detcells > 0) {
|
||||
cfc_frigate_mqtt_config_t yc = {
|
||||
.host = yw_mqtt_host, .port = yw_mqtt_port,
|
||||
.username = mqtt_user, .password = mqtt_pass,
|
||||
.topic = yw_topic,
|
||||
.composer = NULL, /* yolo-world не управляет motion-layout */
|
||||
};
|
||||
if (cfc_frigate_mqtt_create(&yc, &yw_mqtt) == 0) {
|
||||
for (int i = 0; i < num_detcells; i++) {
|
||||
if (yw_detbox_overlays[i]) {
|
||||
cfc_frigate_mqtt_register_overlay(yw_mqtt, yw_detbox_overlays[i]);
|
||||
}
|
||||
}
|
||||
cfc_frigate_mqtt_start(yw_mqtt);
|
||||
fprintf(stderr, "[grid_record] yw_mqtt started → %s:%d topic=%s\n",
|
||||
yw_mqtt_host, yw_mqtt_port, yw_topic);
|
||||
} else {
|
||||
fprintf(stderr, "[grid_record] yw_mqtt create failed\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* PNG иконки. */
|
||||
for (int i = 0; i < num_icons; i++) {
|
||||
cfc_overlay_png_config_t pc = {
|
||||
@@ -777,6 +886,7 @@ int main(int argc, char **argv)
|
||||
|
||||
cfc_writer_close(wctx.writer);
|
||||
if (frigate) cfc_frigate_mqtt_destroy(frigate);
|
||||
if (yw_mqtt) cfc_frigate_mqtt_destroy(yw_mqtt);
|
||||
if (audio) cfc_audio_destroy(audio);
|
||||
if (ctl) cfc_control_destroy(ctl);
|
||||
if (hpub) cfc_health_destroy(hpub);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/* grid_record_cpp — Phase 11b ООП-гипотеза.
|
||||
*
|
||||
* Минимальный smoke: проверяет что C++ модель Cell/Layout/Decoration/Composer
|
||||
* компилируется, линкуется с C-частями, и реально рисует кадры через те же
|
||||
* CUDA-kernels (zero-copy: единый NV12 буфер не копируется между cells).
|
||||
*
|
||||
* Делает N композиций → dump последнего NV12 кадра в файл → exit.
|
||||
* Без NVENC: цель гипотезы — не encode, а доказать что ООП-pipeline работает.
|
||||
*
|
||||
* Использование:
|
||||
* grid_record_cpp --out /tmp/last.nv12 --frames 50 \
|
||||
* --templates /opt/templates.json \
|
||||
* --source cam-parking,frigate=parking_overview,priority=100 \
|
||||
* --source cam-back_yard,frigate=back_yard,priority=70 \
|
||||
* --motion-mode
|
||||
*/
|
||||
|
||||
#include "../include/cuframes_composer/cpp/composer.hpp"
|
||||
|
||||
#include <cuda.h>
|
||||
#include <cuda_runtime.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <getopt.h>
|
||||
#include <signal.h>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
volatile sig_atomic_t g_stop = 0;
|
||||
void on_sig(int) { g_stop = 1; }
|
||||
|
||||
struct SourceSpec {
|
||||
std::string key;
|
||||
std::string frigate;
|
||||
int priority = 0;
|
||||
std::vector<std::string> zones;
|
||||
};
|
||||
|
||||
std::vector<std::string> split_colon(const std::string& s)
|
||||
{
|
||||
std::vector<std::string> out;
|
||||
std::string cur;
|
||||
for (char c : s) {
|
||||
if (c == ':') { if (!cur.empty()) out.push_back(cur); cur.clear(); }
|
||||
else cur.push_back(c);
|
||||
}
|
||||
if (!cur.empty()) out.push_back(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
SourceSpec parse_source(const std::string& arg)
|
||||
{
|
||||
SourceSpec s;
|
||||
std::vector<std::string> parts;
|
||||
std::string cur;
|
||||
for (char c : arg) {
|
||||
if (c == ',') { parts.push_back(cur); cur.clear(); }
|
||||
else cur.push_back(c);
|
||||
}
|
||||
if (!cur.empty()) parts.push_back(cur);
|
||||
if (parts.empty()) return s;
|
||||
s.key = parts[0];
|
||||
for (std::size_t i = 1; i < parts.size(); i++) {
|
||||
auto& p = parts[i];
|
||||
if (p.rfind("frigate=", 0) == 0) s.frigate = p.substr(8);
|
||||
else if (p.rfind("priority=", 0) == 0) s.priority = std::atoi(p.c_str() + 9);
|
||||
else if (p.rfind("zones=", 0) == 0) s.zones = split_colon(p.substr(6));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
std::string out_path;
|
||||
std::string templates_path;
|
||||
int width = 1920, height = 1080;
|
||||
int frames_to_compose = 25;
|
||||
bool motion_mode = false;
|
||||
int motion_ttl = 45000;
|
||||
std::vector<SourceSpec> sources;
|
||||
|
||||
static struct option opts[] = {
|
||||
{"out", required_argument, 0, 'o'},
|
||||
{"frames", required_argument, 0, 'n'},
|
||||
{"width", required_argument, 0, 'W'},
|
||||
{"height", required_argument, 0, 'H'},
|
||||
{"source", required_argument, 0, 'S'},
|
||||
{"motion-mode",no_argument, 0, 'm'},
|
||||
{"motion-ttl", required_argument, 0, 'k'},
|
||||
{"templates", required_argument, 0, 'z'},
|
||||
{0, 0, 0, 0},
|
||||
};
|
||||
int c;
|
||||
while ((c = getopt_long(argc, argv, "o:n:W:H:S:mk:z:", opts, nullptr)) != -1) {
|
||||
switch (c) {
|
||||
case 'o': out_path = optarg; break;
|
||||
case 'n': frames_to_compose = std::atoi(optarg); break;
|
||||
case 'W': width = std::atoi(optarg); break;
|
||||
case 'H': height = std::atoi(optarg); break;
|
||||
case 'S': sources.push_back(parse_source(optarg)); break;
|
||||
case 'm': motion_mode = true; break;
|
||||
case 'k': motion_ttl = std::atoi(optarg); break;
|
||||
case 'z': templates_path = optarg; break;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
if (out_path.empty()) {
|
||||
std::fprintf(stderr, "Usage: %s --out FILE --source ... [--motion-mode]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
signal(SIGINT, on_sig);
|
||||
signal(SIGTERM, on_sig);
|
||||
|
||||
cuInit(0);
|
||||
CUdevice dev; cuDeviceGet(&dev, 0);
|
||||
CUcontext ctx; cuDevicePrimaryCtxRetain(&ctx, dev);
|
||||
cuCtxPushCurrent(ctx);
|
||||
|
||||
cfc::ComposerConfig ccfg;
|
||||
ccfg.width = width;
|
||||
ccfg.height = height;
|
||||
ccfg.templates_path = templates_path;
|
||||
ccfg.motion_ttl_ms = motion_ttl;
|
||||
|
||||
cfc::Composer composer(ccfg);
|
||||
if (!composer.ok()) {
|
||||
std::fprintf(stderr, "[smoke] composer init failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
cfc::SourcePool::SubscribeOpts opts_sub;
|
||||
for (auto& s : sources) {
|
||||
composer.pool().add(s.key, s.frigate, s.priority, s.zones, opts_sub);
|
||||
}
|
||||
if (motion_mode) composer.set_motion_mode(true, motion_ttl);
|
||||
|
||||
std::fprintf(stderr, "[smoke] composer %dx%d templates=%d sources=%zu motion=%d\n",
|
||||
width, height, composer.templates_count(), sources.size(),
|
||||
motion_mode ? 1 : 0);
|
||||
|
||||
/* Несколько композиций — даём sources подключиться. */
|
||||
cfc::NV12Ref last{};
|
||||
for (int i = 0; i < frames_to_compose && !g_stop; i++) {
|
||||
last = composer.compose_frame();
|
||||
cudaStreamSynchronize(0);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(40));
|
||||
}
|
||||
|
||||
/* Dump последнего кадра в файл. */
|
||||
std::size_t y_size = static_cast<std::size_t>(last.pitch_y) * height;
|
||||
std::size_t uv_size = static_cast<std::size_t>(last.pitch_uv) * (height / 2);
|
||||
std::vector<unsigned char> host(y_size + uv_size);
|
||||
cuMemcpyDtoH(host.data(), last.y_ptr, y_size);
|
||||
cuMemcpyDtoH(host.data() + y_size, last.uv_ptr, uv_size);
|
||||
|
||||
FILE* f = std::fopen(out_path.c_str(), "wb");
|
||||
if (!f) { std::fprintf(stderr, "[smoke] open '%s' failed\n", out_path.c_str()); return 1; }
|
||||
std::fwrite(host.data(), 1, host.size(), f);
|
||||
std::fclose(f);
|
||||
std::fprintf(stderr, "[smoke] wrote %zu bytes (Y=%zu UV=%zu) to %s\n",
|
||||
host.size(), y_size, uv_size, out_path.c_str());
|
||||
std::fprintf(stderr, "[smoke] current template: '%s'\n",
|
||||
composer.current_layout_name().c_str());
|
||||
|
||||
cuCtxPopCurrent(nullptr);
|
||||
cuDevicePrimaryCtxRelease(dev);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* BlankCell — пустая cell (Phase 11b).
|
||||
*
|
||||
* Используется для "идущего идле" слота без камеры — рисует чёрный rect
|
||||
* на месте cell. Альтернативно может быть placeholder с надписью "NO SIGNAL"
|
||||
* через LabelDecoration.
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_BLANK_CELL_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_BLANK_CELL_HPP
|
||||
|
||||
#include "cell.hpp"
|
||||
|
||||
namespace cfc {
|
||||
|
||||
class BlankCell : public Cell {
|
||||
public:
|
||||
explicit BlankCell(const Rect& geom) : Cell(geom) {}
|
||||
|
||||
protected:
|
||||
void draw_content(CUstream stream, NV12Ref& dst) override;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_BLANK_CELL_HPP */
|
||||
@@ -0,0 +1,39 @@
|
||||
/* BorderDecoration — рамка вокруг cell (Phase 11b).
|
||||
*
|
||||
* 4 узких прямоугольника (top/bottom/left/right) через cfc_cugrid_fill_nv12.
|
||||
* Полезна для подсветки main cell в layout'е или recording-indicator'ов.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_BORDER_DECORATION_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_BORDER_DECORATION_HPP
|
||||
|
||||
#include "decoration.hpp"
|
||||
|
||||
namespace cfc {
|
||||
|
||||
struct BorderStyle {
|
||||
int thickness = 3;
|
||||
int color_y = 210, color_u = 50, color_v = 100; /* BT.709 limited */
|
||||
int alpha = 240;
|
||||
bool visible = true;
|
||||
};
|
||||
|
||||
class BorderDecoration : public Decoration {
|
||||
public:
|
||||
explicit BorderDecoration(const BorderStyle& style) : style_(style) {}
|
||||
~BorderDecoration() override = default;
|
||||
|
||||
void set_visible(bool v) noexcept { style_.visible = v; }
|
||||
void set_style(const BorderStyle& s) noexcept { style_ = s; }
|
||||
|
||||
void draw(CUstream stream, NV12Ref& dst, const Rect& parent_rect) override;
|
||||
|
||||
private:
|
||||
BorderStyle style_;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_BORDER_DECORATION_HPP */
|
||||
@@ -0,0 +1,43 @@
|
||||
/* CameraCell — рисует кадр из cuframes-источника в свой Rect (Phase 11b).
|
||||
*
|
||||
* Cell держит non-owning указатель на cfc_source_t (живёт в SourcePool
|
||||
* композитора). На каждом draw_content():
|
||||
* 1. cfc_source_get_latest — snapshot последнего кадра в VRAM
|
||||
* 2. если ACTIVE/STALE — cfc_cugrid_resize_nv12 в свою geom_
|
||||
* 3. если DEAD/CONNECTING — пропуск (cell остаётся blacked out)
|
||||
*
|
||||
* Decorations (label, border) рисуются в Cell::draw() поверх content'а.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_CAMERA_CELL_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_CAMERA_CELL_HPP
|
||||
|
||||
#include "../source.h"
|
||||
#include "cell.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
class CameraCell : public Cell {
|
||||
public:
|
||||
CameraCell(const Rect& geom, cfc_source_t* source, std::string source_key = {})
|
||||
: Cell(geom), source_(source), source_key_(std::move(source_key)) {}
|
||||
|
||||
void set_source(cfc_source_t* src) noexcept { source_ = src; }
|
||||
cfc_source_t* source() const noexcept { return source_; }
|
||||
const std::string& source_key() const noexcept { return source_key_; }
|
||||
|
||||
protected:
|
||||
void draw_content(CUstream stream, NV12Ref& dst) override;
|
||||
|
||||
private:
|
||||
cfc_source_t* source_; /* non-owning — pool владеет */
|
||||
std::string source_key_;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_CAMERA_CELL_HPP */
|
||||
@@ -0,0 +1,64 @@
|
||||
/* Cell — базовый абстрактный класс ячейки композитора (Phase 11b).
|
||||
*
|
||||
* Cell — это прямоугольная область output frame, рисуемая в свою geom_.
|
||||
* Реализации (CameraCell, WidgetCell, BlankCell) определяют content-рендер;
|
||||
* декорации (Label, Border) добавляются композицией через add_decoration().
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. Layout::apply_template() создаёт нужные Cell-подклассы.
|
||||
* 2. На каждом compose: cell.draw(stream, dst) рисует свой контент
|
||||
* + все decorations.
|
||||
* 3. Layout уничтожает cells при apply нового template'а.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_CELL_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_CELL_HPP
|
||||
|
||||
#include "decoration.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
class Cell {
|
||||
public:
|
||||
explicit Cell(const Rect& geom) : geom_(geom) {}
|
||||
virtual ~Cell() = default;
|
||||
|
||||
Cell(const Cell&) = delete;
|
||||
Cell& operator=(const Cell&) = delete;
|
||||
|
||||
/* Геометрия cell в pixel-координатах output frame. */
|
||||
const Rect& geometry() const noexcept { return geom_; }
|
||||
void set_geometry(const Rect& r) noexcept { geom_ = r; }
|
||||
|
||||
/* Добавить decoration (cell takes ownership). */
|
||||
void add_decoration(std::unique_ptr<Decoration> d) {
|
||||
decorations_.push_back(std::move(d));
|
||||
}
|
||||
|
||||
/* Основной hook: рисует content + все decorations. Реализации обычно
|
||||
* переопределяют только draw_content(), а draw_decorations() общий. */
|
||||
void draw(CUstream stream, NV12Ref& dst) {
|
||||
if (geom_.empty()) return;
|
||||
draw_content(stream, dst);
|
||||
for (auto& dec : decorations_) {
|
||||
dec->draw(stream, dst, geom_);
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
/* Реализуется подклассом. */
|
||||
virtual void draw_content(CUstream stream, NV12Ref& dst) = 0;
|
||||
|
||||
Rect geom_;
|
||||
std::vector<std::unique_ptr<Decoration>> decorations_;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_CELL_HPP */
|
||||
@@ -0,0 +1,152 @@
|
||||
/* Composer — оркестратор Phase 11b.
|
||||
*
|
||||
* Owns:
|
||||
* - SourcePool (cuframes-источники + motion state)
|
||||
* - vector<LayoutTemplate> (loaded from JSON или builtins)
|
||||
* - Layout (текущее состояние cells)
|
||||
* - OutputSurface (CudaBuffer для NV12 output)
|
||||
*
|
||||
* Compose loop (по кадру):
|
||||
* 1. select_template_and_active() → (LayoutTemplate*, vector<PoolEntry*>)
|
||||
* по правилам: motion_mode? motion-based best-fit : idle top-1 single
|
||||
* 2. hysteresis: рост сразу, уменьшение — wait shrink_hysteresis_ms
|
||||
* 3. если sig != committed → layout_.apply(template, active, W, H)
|
||||
* 4. compose_clear() → output буфер чёрный
|
||||
* 5. layout_.render(stream, NV12Ref)
|
||||
*
|
||||
* Public API экспортируется через composer_c_api.cpp с extern "C" для
|
||||
* совместимости с control.c, grid_record.c, frigate_mqtt.c.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_COMPOSER_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_COMPOSER_HPP
|
||||
|
||||
#include "../overlay.h" /* C API — backward compat для CLI overlays */
|
||||
#include "cuda_raii.hpp"
|
||||
#include "layout.hpp"
|
||||
#include "source_pool.hpp"
|
||||
#include "template.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
#include <cuda_runtime.h>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
struct ComposerConfig {
|
||||
int width = 1920;
|
||||
int height = 1080;
|
||||
int cuda_device = 0;
|
||||
int bg_y = 16, bg_u = 128, bg_v = 128;
|
||||
|
||||
/* Motion-mode параметры. */
|
||||
int motion_ttl_ms = 45000;
|
||||
int shrink_hysteresis_ms = 3000;
|
||||
|
||||
/* Templates JSON path (empty → built-in). */
|
||||
std::string templates_path;
|
||||
};
|
||||
|
||||
class Composer {
|
||||
public:
|
||||
explicit Composer(const ComposerConfig& cfg);
|
||||
~Composer();
|
||||
|
||||
Composer(const Composer&) = delete;
|
||||
Composer& operator=(const Composer&) = delete;
|
||||
|
||||
bool ok() const noexcept { return output_.ok(); }
|
||||
|
||||
SourcePool& pool() noexcept { return pool_; }
|
||||
const SourcePool& pool() const noexcept { return pool_; }
|
||||
|
||||
/* Motion mode + relayout policy. */
|
||||
void set_motion_mode(bool on, int ttl_ms = 0);
|
||||
bool motion_mode() const noexcept { return motion_mode_; }
|
||||
|
||||
/* Загрузить templates из JSON. Возвращает количество, либо <0. */
|
||||
int load_templates(const std::string& path);
|
||||
|
||||
/* Перейти на named template (только если motion_mode == false). */
|
||||
bool set_layout(const std::string& name);
|
||||
|
||||
const std::string& current_layout_name() const noexcept {
|
||||
return layout_.name();
|
||||
}
|
||||
int templates_count() const noexcept {
|
||||
return static_cast<int>(templates_.size());
|
||||
}
|
||||
const std::vector<LayoutTemplate>& templates() const noexcept {
|
||||
return templates_;
|
||||
}
|
||||
|
||||
/* Overlays — backward compat для grid_record.c CLI (--text/--icon/--border)
|
||||
* и Frigate detection_boxes. Рисуются на выходе compose_frame ПОСЛЕ Layout.
|
||||
* Composer takes ownership — destroy()'ит на ~Composer(). */
|
||||
int add_overlay(cfc_overlay_t* ov);
|
||||
cfc_overlay_t* find_overlay(const std::string& id) const;
|
||||
|
||||
/* Health отчёт для C ABI shim. */
|
||||
struct Health {
|
||||
int total = 0;
|
||||
int active = 0;
|
||||
int stale = 0;
|
||||
int dead = 0;
|
||||
};
|
||||
Health get_health() const;
|
||||
|
||||
/* Manual cells — для C API без motion-mode (grid_record --cell без --motion-mode).
|
||||
* Каждый вход {source_key, rect} рендерится CameraCell без template'а. */
|
||||
void set_manual_cells(const std::vector<std::pair<std::string, Rect>>& cells);
|
||||
|
||||
/* Один кадр: relayout (если нужно) + clear + render.
|
||||
* Возвращает NV12Ref на output (ptr действителен до следующего compose). */
|
||||
NV12Ref compose_frame();
|
||||
|
||||
private:
|
||||
/* Selection + hysteresis. */
|
||||
const LayoutTemplate* pick_best_fit(int need) const;
|
||||
std::vector<PoolEntry*> collect_active() const;
|
||||
void maybe_relayout();
|
||||
static std::string build_signature(const std::string& tpl_name,
|
||||
const std::vector<PoolEntry*>& active);
|
||||
|
||||
ComposerConfig cfg_;
|
||||
SourcePool pool_;
|
||||
std::vector<LayoutTemplate> templates_;
|
||||
Layout layout_;
|
||||
|
||||
/* Output NV12 буфер (VMM, zero-copy для NVENC). */
|
||||
CudaBuffer output_;
|
||||
int pitch_y_ = 0;
|
||||
int pitch_uv_ = 0;
|
||||
|
||||
cudaStream_t stream_ = nullptr; /* default = 0 */
|
||||
|
||||
bool motion_mode_ = false;
|
||||
std::int64_t committed_at_ms_ = 0;
|
||||
std::int64_t pending_first_seen_ms_ = 0;
|
||||
std::string committed_signature_;
|
||||
std::string pending_signature_;
|
||||
|
||||
/* Manual override (PTZ через set_layout): пока now < manual_override_until_ms_
|
||||
* motion-mode "заморожен", композитор держит зафиксированный layout. */
|
||||
std::int64_t manual_override_until_ms_ = 0;
|
||||
int manual_override_duration_ms_ = 60000;
|
||||
|
||||
/* Backward-compat overlay list (CLI overlays + detbox). */
|
||||
std::vector<cfc_overlay_t*> overlays_;
|
||||
|
||||
/* Manual cells — alternative режим без motion-mode (grid_record --cell). */
|
||||
std::vector<std::pair<std::string, Rect>> manual_cells_;
|
||||
bool manual_applied_ = false;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_COMPOSER_HPP */
|
||||
@@ -0,0 +1,134 @@
|
||||
/* RAII обёртки над CUDA Driver/Runtime ресурсами (Phase 11b).
|
||||
*
|
||||
* Передача handle'ов между объектами по-прежнему zero-copy (CUdeviceptr —
|
||||
* это unsigned long long; обмен идентичен plain C-коду). Эти обёртки только
|
||||
* автоматизируют lifetime — без них приходилось бы вручную помнить про
|
||||
* cuMemFree и закрывать stream'ы в путях ошибок.
|
||||
*
|
||||
* NB: классы non-copyable (чтобы не вызвать двойной cuMemFree), но movable.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_CUDA_RAII_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_CUDA_RAII_HPP
|
||||
|
||||
#include <cuda.h>
|
||||
#include <cuda_runtime.h>
|
||||
#include <utility>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
/* VMM-allocated NV12 буфер для output / staging. Используется и compose,
|
||||
* и NVENC (через тот же CUdeviceptr — zero-copy). */
|
||||
class CudaBuffer {
|
||||
public:
|
||||
CudaBuffer() = default;
|
||||
|
||||
/* Аллокация в ctor; бросать исключения не хочется — проверяем ok(). */
|
||||
explicit CudaBuffer(std::size_t bytes) {
|
||||
if (cuMemAlloc(&ptr_, bytes) == CUDA_SUCCESS) {
|
||||
size_ = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
~CudaBuffer() { reset(); }
|
||||
|
||||
CudaBuffer(const CudaBuffer&) = delete;
|
||||
CudaBuffer& operator=(const CudaBuffer&) = delete;
|
||||
|
||||
CudaBuffer(CudaBuffer&& other) noexcept
|
||||
: ptr_(other.ptr_), size_(other.size_) {
|
||||
other.ptr_ = 0;
|
||||
other.size_ = 0;
|
||||
}
|
||||
CudaBuffer& operator=(CudaBuffer&& other) noexcept {
|
||||
if (this != &other) {
|
||||
reset();
|
||||
ptr_ = other.ptr_;
|
||||
size_ = other.size_;
|
||||
other.ptr_ = 0;
|
||||
other.size_ = 0;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
void reset() noexcept {
|
||||
if (ptr_) {
|
||||
cuMemFree(ptr_);
|
||||
ptr_ = 0;
|
||||
size_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
CUdeviceptr ptr() const noexcept { return ptr_; }
|
||||
std::size_t size() const noexcept { return size_; }
|
||||
bool ok() const noexcept { return ptr_ != 0; }
|
||||
|
||||
private:
|
||||
CUdeviceptr ptr_ = 0;
|
||||
std::size_t size_ = 0;
|
||||
};
|
||||
|
||||
/* CUDA stream — owner. Композитор использует default stream (Phase 2/3),
|
||||
* но обёртка готова к stream-pipelining (Phase 12+). */
|
||||
class CudaStream {
|
||||
public:
|
||||
CudaStream() = default;
|
||||
|
||||
/* Создать non-default stream. */
|
||||
static CudaStream create() {
|
||||
CudaStream s;
|
||||
cudaStreamCreate(&s.stream_);
|
||||
s.owned_ = (s.stream_ != nullptr);
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Обёртка над уже существующим stream'ом (не владеет). */
|
||||
static CudaStream wrap(cudaStream_t s) noexcept {
|
||||
CudaStream w;
|
||||
w.stream_ = s;
|
||||
w.owned_ = false;
|
||||
return w;
|
||||
}
|
||||
|
||||
~CudaStream() { reset(); }
|
||||
|
||||
CudaStream(const CudaStream&) = delete;
|
||||
CudaStream& operator=(const CudaStream&) = delete;
|
||||
|
||||
CudaStream(CudaStream&& other) noexcept
|
||||
: stream_(other.stream_), owned_(other.owned_) {
|
||||
other.stream_ = nullptr;
|
||||
other.owned_ = false;
|
||||
}
|
||||
CudaStream& operator=(CudaStream&& other) noexcept {
|
||||
if (this != &other) {
|
||||
reset();
|
||||
stream_ = other.stream_;
|
||||
owned_ = other.owned_;
|
||||
other.stream_ = nullptr;
|
||||
other.owned_ = false;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
void reset() noexcept {
|
||||
if (owned_ && stream_) {
|
||||
cudaStreamDestroy(stream_);
|
||||
}
|
||||
stream_ = nullptr;
|
||||
owned_ = false;
|
||||
}
|
||||
|
||||
cudaStream_t handle() const noexcept { return stream_; }
|
||||
CUstream cu_handle() const noexcept { return reinterpret_cast<CUstream>(stream_); }
|
||||
|
||||
private:
|
||||
cudaStream_t stream_ = nullptr;
|
||||
bool owned_ = false;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_CUDA_RAII_HPP */
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Decoration — украшение поверх cell (Phase 11b).
|
||||
*
|
||||
* Cell держит vector<unique_ptr<Decoration>> и вызывает draw() каждого
|
||||
* после своего content-рендера. Decorations знают только Rect cell'а
|
||||
* (для позиционирования относительно неё) и пишут в тот же NV12Ref.
|
||||
*
|
||||
* Типы (минимум):
|
||||
* LabelDecoration — текстовая подпись (FreeType atlas), позиция = угол cell
|
||||
* BorderDecoration — рамка thickness px (4 fill_nv12 — top/bottom/left/right)
|
||||
*
|
||||
* Расширяется: BadgeDecoration, MotionIndicator, RecordingDot и т.д.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_DECORATION_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_DECORATION_HPP
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
namespace cfc {
|
||||
|
||||
class Decoration {
|
||||
public:
|
||||
virtual ~Decoration() = default;
|
||||
|
||||
/* Нарисовать поверх parent_rect. NV12Ref общий с cell'ом. */
|
||||
virtual void draw(CUstream stream, NV12Ref& dst, const Rect& parent_rect) = 0;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_DECORATION_HPP */
|
||||
@@ -0,0 +1,78 @@
|
||||
/* LabelDecoration — текстовая подпись поверх cell (Phase 11b).
|
||||
*
|
||||
* Рендерит UTF-8 строку через FreeType в RGBA-атлас (создаётся один раз
|
||||
* при ctor/set_text, держится в VRAM), затем на каждом draw() блитит
|
||||
* атлас в указанный угол parent_rect через cfc_cugrid_blit_rgba_nv12.
|
||||
*
|
||||
* Корнер: top-left (cell.x + pad, cell.y + pad). Pad по умолчанию 8 px.
|
||||
* Цвет, размер шрифта, alpha-множитель задаются в ctor.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_LABEL_DECORATION_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_LABEL_DECORATION_HPP
|
||||
|
||||
#include "decoration.hpp"
|
||||
|
||||
#include <ft2build.h>
|
||||
#include FT_FREETYPE_H
|
||||
|
||||
#include <cuda.h>
|
||||
#include <string>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
struct LabelStyle {
|
||||
std::string font_path = "/fonts/DejaVuSans-Bold.ttf";
|
||||
int pixel_size = 22;
|
||||
int r = 255, g = 220, b = 64; /* жёлто-оранжевый, читается на любом фоне */
|
||||
int alpha = 255; /* множитель прозрачности 0..255 */
|
||||
int pad = 8; /* отступ от угла cell */
|
||||
|
||||
/* visible можно переключать без перерендера атласа. */
|
||||
bool visible = true;
|
||||
};
|
||||
|
||||
class LabelDecoration : public Decoration {
|
||||
public:
|
||||
LabelDecoration(const std::string& text, const LabelStyle& style);
|
||||
~LabelDecoration() override;
|
||||
|
||||
LabelDecoration(const LabelDecoration&) = delete;
|
||||
LabelDecoration& operator=(const LabelDecoration&) = delete;
|
||||
|
||||
void set_visible(bool v) noexcept { style_.visible = v; }
|
||||
bool visible() const noexcept { return style_.visible; }
|
||||
|
||||
/* Обновить текст (re-render atlas). Передвижение/visible — без re-render. */
|
||||
void set_text(const std::string& text);
|
||||
|
||||
void draw(CUstream stream, NV12Ref& dst, const Rect& parent_rect) override;
|
||||
|
||||
private:
|
||||
/* Pass1: измерить bbox строки + ascent для baseline'а. */
|
||||
bool measure(int& w, int& h, int& ascent) const;
|
||||
/* Pass2: отрисовать строку в RGBA-буфер CPU. */
|
||||
void render_to_cpu(unsigned char* rgba, int w, int h, int ascent) const;
|
||||
/* Rebuild VRAM atlas из текущей строки. */
|
||||
bool rebuild_atlas();
|
||||
|
||||
/* FreeType state. */
|
||||
FT_Library ft_lib_ = nullptr;
|
||||
FT_Face face_ = nullptr;
|
||||
|
||||
/* Текст и стиль. */
|
||||
std::string text_;
|
||||
LabelStyle style_;
|
||||
|
||||
/* VRAM atlas. */
|
||||
CUdeviceptr atlas_ = 0;
|
||||
int atlas_w_ = 0;
|
||||
int atlas_h_ = 0;
|
||||
int atlas_pitch_ = 0;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_LABEL_DECORATION_HPP */
|
||||
@@ -0,0 +1,62 @@
|
||||
/* Layout — контейнер cells и оркестратор apply_template (Phase 11b).
|
||||
*
|
||||
* Layout::apply() принимает LayoutTemplate + список активных pool-entries
|
||||
* (sorted by priority DESC) + output W×H. Создаёт нужные Cell-подклассы:
|
||||
*
|
||||
* CameraCell для каждой template-cell с role=CAMERA — берёт source
|
||||
* по индексу из active list (active[0]=order 0, active[1]=order 1, ...)
|
||||
* Если active меньше чем camera-cells — лишние cells = BlankCell.
|
||||
* WidgetCell для template-cell с role=WIDGET — placeholder.
|
||||
*
|
||||
* Decorations добавляются здесь же:
|
||||
* LabelDecoration "{key} prio={N}" в каждый CameraCell.
|
||||
* LabelDecoration с именем widget'а в каждый WidgetCell.
|
||||
* (Border, Badge — Phase 12+)
|
||||
*
|
||||
* Layout::render(stream, dst) — итеративно вызывает cell->draw().
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_LAYOUT_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_LAYOUT_HPP
|
||||
|
||||
#include "cell.hpp"
|
||||
#include "source_pool.hpp"
|
||||
#include "template.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
class Layout {
|
||||
public:
|
||||
Layout() = default;
|
||||
|
||||
/* Применить template — пересоздаёт cells + decorations.
|
||||
* active_sorted — список pool-entries, уже отсортированный priority DESC. */
|
||||
void apply(const LayoutTemplate& tpl,
|
||||
const std::vector<PoolEntry*>& active_sorted,
|
||||
int frame_w, int frame_h);
|
||||
|
||||
/* Прорисовать все cells в dst буфер. */
|
||||
void render(CUstream stream, NV12Ref& dst);
|
||||
|
||||
const std::string& name() const noexcept { return current_name_; }
|
||||
int cell_count() const noexcept { return static_cast<int>(cells_.size()); }
|
||||
|
||||
/* Найти текущий pixel-rect для камеры с заданным cuframes-key. NULL
|
||||
* если этой камеры в layout сейчас нет. Используется detbox-overlay'ями
|
||||
* для пересчёта bbox при смене layout. */
|
||||
const Rect* find_camera_cell_rect(const std::string& source_key) const;
|
||||
|
||||
private:
|
||||
std::vector<std::unique_ptr<Cell>> cells_;
|
||||
std::string current_name_;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_LAYOUT_HPP */
|
||||
@@ -0,0 +1,123 @@
|
||||
/* MqttOverlay — generic MQTT-driven text overlay (Phase 11b).
|
||||
*
|
||||
* Каждый overlay = одна MQTT-подписка + один persistent text overlay.
|
||||
* Конфиг загружается из JSON-файла (mqtt_overlays.json):
|
||||
* {
|
||||
* "overlays": [
|
||||
* { "id": "temp_outside",
|
||||
* "topic": "zigbee2mqtt/Температура на улице",
|
||||
* "json_field": "temperature", // если payload JSON; пусто — raw string
|
||||
* "format": "%+.1f°C", // printf для extracted значения
|
||||
* "anchor": "right-bottom", // right-top, left-bottom, ...
|
||||
* "margin_x": 32, "margin_y": 24,
|
||||
* "pixel_size": 32,
|
||||
* "color": [255, 255, 255], "alpha": 230,
|
||||
* "font_path": "/fonts/DejaVuSans-Bold.ttf"
|
||||
* }, ...
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Менеджер (MqttOverlayManager) держит vector<MqttOverlayItem>, поднимает
|
||||
* MQTT-клиентов и добавляет overlays в композер. Hot-reload через
|
||||
* reload_from_file() — пересоздаёт всех subscribers и overlays.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_MQTT_OVERLAY_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_MQTT_OVERLAY_HPP
|
||||
|
||||
#include "../overlay.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct mosquitto;
|
||||
struct mosquitto_message;
|
||||
|
||||
namespace cfc {
|
||||
|
||||
struct MqttOverlayCfg {
|
||||
std::string id;
|
||||
std::string topic;
|
||||
std::string json_field; /* если payload JSON; пусто = raw string */
|
||||
std::string format = "%s"; /* printf-formatted (для double — "%+.1f°C") */
|
||||
std::string anchor = "right-bottom"; /* right-top, left-bottom, ... */
|
||||
int margin_x = 32, margin_y = 24;
|
||||
int pixel_size = 32;
|
||||
int r = 255, g = 255, b = 255;
|
||||
int alpha = 230;
|
||||
std::string font_path = "/fonts/DejaVuSans-Bold.ttf";
|
||||
|
||||
/* Полупрозрачная подложка. bg_alpha=0 → отключено. */
|
||||
int bg_alpha = 160;
|
||||
int bg_y = 16, bg_u = 128, bg_v = 128; /* по умолчанию чёрный */
|
||||
int bg_pad = 10;
|
||||
|
||||
/* Что показывать пока нет MQTT-данных. Пусто → overlay невидим до
|
||||
* первого сообщения. По умолчанию "—" чтобы было видно что overlay
|
||||
* жив, но данные ещё не пришли. */
|
||||
std::string placeholder = "—";
|
||||
};
|
||||
|
||||
struct MqttBrokerCfg {
|
||||
std::string host = "cctv-mosquitto";
|
||||
int port = 1883;
|
||||
std::string username;
|
||||
std::string password;
|
||||
};
|
||||
|
||||
class MqttOverlayItem {
|
||||
public:
|
||||
MqttOverlayItem(const MqttOverlayCfg& cfg, const MqttBrokerCfg& broker,
|
||||
int frame_w, int frame_h);
|
||||
~MqttOverlayItem();
|
||||
MqttOverlayItem(const MqttOverlayItem&) = delete;
|
||||
MqttOverlayItem& operator=(const MqttOverlayItem&) = delete;
|
||||
|
||||
bool start();
|
||||
cfc_overlay_t* overlay() const { return overlay_; }
|
||||
const std::string& id() const { return cfg_.id; }
|
||||
|
||||
private:
|
||||
static void on_connect(struct mosquitto* m, void* user, int rc);
|
||||
static void on_message(struct mosquitto* m, void* user,
|
||||
const struct mosquitto_message* msg);
|
||||
|
||||
void handle_payload(const char* payload, std::size_t len);
|
||||
void update_text(const std::string& text);
|
||||
void reposition_overlay();
|
||||
|
||||
MqttOverlayCfg cfg_;
|
||||
MqttBrokerCfg broker_;
|
||||
int frame_w_, frame_h_;
|
||||
struct mosquitto* mosq_ = nullptr;
|
||||
cfc_overlay_t* overlay_ = nullptr;
|
||||
std::atomic<bool> running_{false};
|
||||
std::string last_text_;
|
||||
};
|
||||
|
||||
class MqttOverlayManager {
|
||||
public:
|
||||
explicit MqttOverlayManager(const MqttBrokerCfg& broker) : broker_(broker) {}
|
||||
~MqttOverlayManager() = default;
|
||||
|
||||
/* Загрузить overlays из JSON-файла. Возвращает количество созданных. */
|
||||
int load_from_file(const std::string& path, int frame_w, int frame_h);
|
||||
|
||||
/* Pointers на overlays для регистрации в композере. */
|
||||
std::vector<cfc_overlay_t*> overlay_handles() const;
|
||||
|
||||
void clear();
|
||||
int size() const { return static_cast<int>(items_.size()); }
|
||||
|
||||
private:
|
||||
MqttBrokerCfg broker_;
|
||||
std::vector<std::unique_ptr<MqttOverlayItem>> items_;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_MQTT_OVERLAY_HPP */
|
||||
@@ -0,0 +1,96 @@
|
||||
/* SourcePool — пул cuframes-источников композитора (Phase 11b).
|
||||
*
|
||||
* Каждая запись: cuframes_key + frigate_camera + priority + cfc_source_t* +
|
||||
* motion state (last_motion_ms, zone-filter). Pool создаётся при старте
|
||||
* композитора (через add() вызовы) и живёт всю сессию.
|
||||
*
|
||||
* Cells (CameraCell) держат non-owning указатели на cfc_source_t — pool
|
||||
* владеет.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_SOURCE_POOL_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_SOURCE_POOL_HPP
|
||||
|
||||
#include "../source.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
struct PoolEntry {
|
||||
std::string cuframes_key;
|
||||
std::string frigate_camera;
|
||||
int priority = 0;
|
||||
cfc_source_t* source = nullptr;
|
||||
std::atomic<std::int64_t> last_motion_ms{0};
|
||||
std::vector<std::string> required_zones;
|
||||
|
||||
/* Получить snapshot для drawable-checks без локов. */
|
||||
cfc_source_state_t state() const {
|
||||
if (!source) return CFC_SOURCE_DISCONNECTED;
|
||||
cfc_source_snapshot_t s{};
|
||||
cfc_source_get_latest(source, &s);
|
||||
return s.state;
|
||||
}
|
||||
|
||||
bool drawable() const {
|
||||
cfc_source_state_t st = state();
|
||||
return st == CFC_SOURCE_ACTIVE || st == CFC_SOURCE_STALE;
|
||||
}
|
||||
};
|
||||
|
||||
class SourcePool {
|
||||
public:
|
||||
SourcePool() = default;
|
||||
~SourcePool();
|
||||
|
||||
SourcePool(const SourcePool&) = delete;
|
||||
SourcePool& operator=(const SourcePool&) = delete;
|
||||
|
||||
/* Параметры подписки cuframes (default per cfc_source_config_t). */
|
||||
struct SubscribeOpts {
|
||||
int cuda_device = 0;
|
||||
std::string consumer_prefix = "composer";
|
||||
int reconnect_min_ms = 1000;
|
||||
int reconnect_max_ms = 30000;
|
||||
int stale_threshold_ms = 500;
|
||||
int dead_threshold_ms = 5000;
|
||||
};
|
||||
|
||||
/* Добавить источник в pool. Возвращает индекс или -1. */
|
||||
int add(const std::string& cuframes_key,
|
||||
const std::string& frigate_camera,
|
||||
int priority,
|
||||
const std::vector<std::string>& zones,
|
||||
const SubscribeOpts& opts);
|
||||
|
||||
int size() const { return static_cast<int>(entries_.size()); }
|
||||
PoolEntry* by_index(int i) { return i >= 0 && i < size() ? entries_[i].get() : nullptr; }
|
||||
PoolEntry* by_key(const std::string& key);
|
||||
PoolEntry* by_frigate_camera(const std::string& frigate_camera);
|
||||
|
||||
/* Уведомить о motion (вызывается из Frigate MQTT subscriber'а через
|
||||
* C-shim). Если zone-filter задан — проверяет пересечение. */
|
||||
void motion_pulse(const std::string& frigate_camera,
|
||||
const std::vector<std::string>& current_zones);
|
||||
|
||||
/* Итерация (для best-fit selection и health). */
|
||||
template <typename F>
|
||||
void for_each(F&& fn) {
|
||||
for (auto& e : entries_) fn(*e);
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<std::unique_ptr<PoolEntry>> entries_;
|
||||
std::mutex mu_;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_SOURCE_POOL_HPP */
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Layout template — описание сетки в микроячейках (Phase 11b).
|
||||
*
|
||||
* Template — declarative описание layout'а: имя, набор CellTemplate
|
||||
* (col/row/cs/rs/role/order/widget). Layout::apply_template() из template'а
|
||||
* + SourcePool создаёт конкретные Cell-объекты (CameraCell/WidgetCell).
|
||||
*
|
||||
* Грид: 8×8 микроячейки на output W×H. Для 1920×1080 микроячейка = 240×135 (16:9).
|
||||
*
|
||||
* Загружается из JSON через template_loader.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_TEMPLATE_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_TEMPLATE_HPP
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
constexpr int kGridCols = 8;
|
||||
constexpr int kGridRows = 8;
|
||||
|
||||
enum class CellRole {
|
||||
Camera = 0,
|
||||
Widget = 1,
|
||||
};
|
||||
|
||||
struct CellTemplate {
|
||||
int col = 0, row = 0;
|
||||
int cs = 1, rs = 1;
|
||||
CellRole role = CellRole::Camera;
|
||||
int order = 0;
|
||||
std::string widget; /* имя widget'а для role=Widget */
|
||||
};
|
||||
|
||||
struct LayoutTemplate {
|
||||
std::string name;
|
||||
int priority = 0;
|
||||
std::vector<CellTemplate> cells;
|
||||
|
||||
int nb_camera_cells() const {
|
||||
int n = 0;
|
||||
for (auto& c : cells) if (c.role == CellRole::Camera) ++n;
|
||||
return n;
|
||||
}
|
||||
};
|
||||
|
||||
/* Перевести {col,row,cs,rs} в pixel-rect для output W×H. */
|
||||
inline Rect to_pixels(const CellTemplate& c, int W, int H)
|
||||
{
|
||||
Rect r;
|
||||
r.x = (c.col * W) / kGridCols;
|
||||
r.y = (c.row * H) / kGridRows;
|
||||
r.w = (c.cs * W) / kGridCols;
|
||||
r.h = (c.rs * H) / kGridRows;
|
||||
/* NV12 4:2:0 — чётные. */
|
||||
r.x &= ~1; r.y &= ~1; r.w &= ~1; r.h &= ~1;
|
||||
if (r.x + r.w > W) r.w = W - r.x;
|
||||
if (r.y + r.h > H) r.h = H - r.y;
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_TEMPLATE_HPP */
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Template loader — JSON → vector<LayoutTemplate> (Phase 11b).
|
||||
*
|
||||
* Schema см. docker/templates.json. При неудаче возвращает empty vector
|
||||
* (caller использует built-in fallback).
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_TEMPLATE_LOADER_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_TEMPLATE_LOADER_HPP
|
||||
|
||||
#include "template.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
/* Загрузить из файла. Возвращает количество загруженных templates, либо
|
||||
* отрицательное число при ошибке (-1=parse, -2=schema, -3=open). */
|
||||
int load_templates_from_file(const std::string& path,
|
||||
std::vector<LayoutTemplate>& out);
|
||||
|
||||
/* Встроенный набор fallback templates (Phase 11b base — single, quad). */
|
||||
std::vector<LayoutTemplate> builtin_templates();
|
||||
|
||||
/* Global template registry — единый источник для Composer и cfc_layout_*
|
||||
* ABI shim. Заполняется builtin'ами по умолчанию; перезаписывается при
|
||||
* load_templates_from_file (если был успех). Thread-safe — composer и
|
||||
* control-thread читают, hot-reload пишет под lock. */
|
||||
const std::vector<LayoutTemplate>& current_templates();
|
||||
void set_current_templates(std::vector<LayoutTemplate> new_templates);
|
||||
int load_into_current(const std::string& path);
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_TEMPLATE_LOADER_HPP */
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Базовые типы C++-модели композитора (Phase 11b).
|
||||
*
|
||||
* Rect — pixel-координаты в output frame buffer'е (1920×1080 default).
|
||||
* NV12Surface — wrapper над VMM-buffer'ом с pitch_y/pitch_uv для совместного
|
||||
* использования compose и encoder'ом. По сути reference на CUdeviceptr —
|
||||
* никаких копий не делается, ownership держит OutputSurface.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_TYPES_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_TYPES_HPP
|
||||
|
||||
#include <cuda.h>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
/* Прямоугольник в pixel-координатах output frame. Все координаты должны
|
||||
* быть чётными (требование NV12 4:2:0). */
|
||||
struct Rect {
|
||||
int x = 0, y = 0;
|
||||
int w = 0, h = 0;
|
||||
|
||||
bool empty() const noexcept { return w <= 0 || h <= 0; }
|
||||
int right() const noexcept { return x + w; }
|
||||
int bottom() const noexcept { return y + h; }
|
||||
};
|
||||
|
||||
/* Reference на NV12-плоскости в VRAM. НЕ owner — все CUdeviceptr'ы
|
||||
* принадлежат OutputSurface, передаются read-write всем cells/decorations.
|
||||
*
|
||||
* Слои:
|
||||
* y_ptr — Y plane, size = pitch_y * height
|
||||
* uv_ptr — UV plane (interleaved 2:0), size = pitch_uv * height/2
|
||||
*
|
||||
* frame_w / frame_h — размер всего output буфера (для clipping). */
|
||||
struct NV12Ref {
|
||||
CUdeviceptr y_ptr = 0;
|
||||
int pitch_y = 0;
|
||||
CUdeviceptr uv_ptr = 0;
|
||||
int pitch_uv = 0;
|
||||
int frame_w = 0;
|
||||
int frame_h = 0;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_TYPES_HPP */
|
||||
@@ -0,0 +1,34 @@
|
||||
/* WidgetCell — заглушка для widget'а (Phase 11b MVP).
|
||||
*
|
||||
* Фаза 11b: рисует cell тёмно-серым (Y=40) + label-decoration с именем
|
||||
* widget'а в центре. Реальные виджеты (graph, ha_chat) — Phase 12+.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#ifndef CUFRAMES_COMPOSER_CPP_WIDGET_CELL_HPP
|
||||
#define CUFRAMES_COMPOSER_CPP_WIDGET_CELL_HPP
|
||||
|
||||
#include "cell.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
class WidgetCell : public Cell {
|
||||
public:
|
||||
WidgetCell(const Rect& geom, const std::string& widget_name)
|
||||
: Cell(geom), widget_name_(widget_name) {}
|
||||
|
||||
const std::string& widget_name() const noexcept { return widget_name_; }
|
||||
|
||||
protected:
|
||||
void draw_content(CUstream stream, NV12Ref& dst) override;
|
||||
|
||||
private:
|
||||
std::string widget_name_;
|
||||
};
|
||||
|
||||
} // namespace cfc
|
||||
|
||||
#endif /* CUFRAMES_COMPOSER_CPP_WIDGET_CELL_HPP */
|
||||
@@ -105,6 +105,14 @@ typedef struct cfc_overlay_text_config {
|
||||
int r, g, b; /* sRGB цвет 0..255 */
|
||||
int extra_alpha; /* 0..255 общий множитель прозрачности */
|
||||
int visible; /* 0/1 — выводить ли */
|
||||
/* Опциональный полупрозрачный фон (подложка) под текстом.
|
||||
* bg_alpha = 0 → без фона (default)
|
||||
* bg_alpha > 0 → fill rect (atlas_w + 2*bg_pad) × (atlas_h + 2*bg_pad)
|
||||
* с цветом bg_y/u/v перед blit'ом текста.
|
||||
* bg_pad чётный, default 8 если bg_alpha>0 и bg_pad==0. */
|
||||
int bg_alpha; /* 0..255 (0 = отключено) */
|
||||
int bg_y, bg_u, bg_v; /* BT.709 limited (Y=16..235, UV=16..240) */
|
||||
int bg_pad; /* px padding вокруг текста */
|
||||
} cfc_overlay_text_config_t;
|
||||
|
||||
/* Создать TEXT overlay. Открывает font через FreeType, рендерит строку
|
||||
@@ -157,6 +165,13 @@ typedef struct cfc_overlay_detbox_config {
|
||||
* NULL или пустой массив → принимать все события. */
|
||||
const char *const *required_zones; /* массив строк */
|
||||
int required_zones_count;
|
||||
|
||||
/* Label + confidence text над bbox.
|
||||
* NULL font_path → рисовать только рамки (legacy behavior).
|
||||
* Текст формата "<label> <pct>%" в pill. Color = тот же что у рамки. */
|
||||
const char *font_path; /* напр. "/fonts/DejaVuSans-Bold.ttf" */
|
||||
int font_size; /* px, рекомендуется 16-20 */
|
||||
int label_bg_alpha; /* 0..255 (default 200 если 0) */
|
||||
} cfc_overlay_detbox_config_t;
|
||||
|
||||
int cfc_overlay_create_detection_boxes(
|
||||
@@ -168,6 +183,13 @@ int cfc_overlay_create_detection_boxes(
|
||||
* правильный overlay по incoming event'у. */
|
||||
const char *cfc_overlay_detbox_camera_key(cfc_overlay_t *ov);
|
||||
|
||||
/* Обновить cell-геометрию runtime (при смене layout композитора). Композитор
|
||||
* вызывает перед draw каждым detbox-overlay'ем — пересчитывает положение
|
||||
* рамки под текущую позицию камеры. */
|
||||
int cfc_overlay_detbox_set_cell_geom(cfc_overlay_t *ov,
|
||||
int cell_x, int cell_y,
|
||||
int cell_w, int cell_h);
|
||||
|
||||
/* Проверить пересечение current_zones события с required_zones overlay'я.
|
||||
* - если required_zones пуст → всегда 1 (filter off)
|
||||
* - если current_zones пуст → 0 (объект вне зон)
|
||||
@@ -180,6 +202,8 @@ int cfc_overlay_detbox_match_zones(cfc_overlay_t *ov,
|
||||
/* Upsert одного active детекта.
|
||||
* event_id — идентификатор Frigate event'а (для трекинга/end).
|
||||
* label — "car", "person", и т.п. (для будущего цветового кодирования).
|
||||
* score — confidence ∈ [0..1] (используется для подписи "label NN%").
|
||||
* Если < 0 — score не рисуется.
|
||||
* x1/y1/x2/y2 — bbox в detect-разрешении (raw Frigate coords).
|
||||
* frame_time_ms — Frigate frame_time для TTL.
|
||||
* Thread-safe (mutex внутри). */
|
||||
@@ -187,6 +211,7 @@ int cfc_overlay_detbox_upsert(
|
||||
cfc_overlay_t *ov,
|
||||
const char *event_id,
|
||||
const char *label,
|
||||
float score,
|
||||
int x1, int y1, int x2, int y2,
|
||||
int64_t frame_time_ms
|
||||
);
|
||||
|
||||
+28
-5
@@ -14,21 +14,40 @@ set(COMPOSER_SOURCES_C
|
||||
source.c
|
||||
nvenc_loader.c
|
||||
nvenc.c
|
||||
composer.c
|
||||
overlay.c
|
||||
control.c
|
||||
health.c
|
||||
writer.c
|
||||
audio.c
|
||||
frigate_mqtt.c
|
||||
layouts.c
|
||||
)
|
||||
# Phase 11b — C++ ООП-модель Cell/Layout/Decoration/Composer + ABI shim.
|
||||
# Заменяет composer.c и layouts.c из Phase 10/11. Старые callers (control.c,
|
||||
# frigate_mqtt.c, examples/grid_record.c) продолжают использовать те же
|
||||
# cfc_composer_* и cfc_layout_* функции — они теперь обёртки над C++ ядром.
|
||||
set(COMPOSER_SOURCES_CPP
|
||||
cpp/label_decoration.cpp
|
||||
cpp/border_decoration.cpp
|
||||
cpp/camera_cell.cpp
|
||||
cpp/widget_cell.cpp
|
||||
cpp/blank_cell.cpp
|
||||
cpp/source_pool.cpp
|
||||
cpp/layout.cpp
|
||||
cpp/template_loader.cpp
|
||||
cpp/composer.cpp
|
||||
cpp/composer_c_api.cpp
|
||||
cpp/layouts_c_api.cpp
|
||||
cpp/mqtt_overlay.cpp
|
||||
cpp/mqtt_overlay_c_api.cpp
|
||||
)
|
||||
set(COMPOSER_SOURCES_CU
|
||||
cugrid/cugrid.cu
|
||||
)
|
||||
|
||||
add_library(cuframes_composer SHARED ${COMPOSER_SOURCES_C} ${COMPOSER_SOURCES_CU})
|
||||
add_library(cuframes_composer_static STATIC ${COMPOSER_SOURCES_C} ${COMPOSER_SOURCES_CU})
|
||||
add_library(cuframes_composer SHARED
|
||||
${COMPOSER_SOURCES_C} ${COMPOSER_SOURCES_CPP} ${COMPOSER_SOURCES_CU})
|
||||
add_library(cuframes_composer_static STATIC
|
||||
${COMPOSER_SOURCES_C} ${COMPOSER_SOURCES_CPP} ${COMPOSER_SOURCES_CU})
|
||||
|
||||
foreach(target cuframes_composer cuframes_composer_static)
|
||||
target_include_directories(${target}
|
||||
@@ -39,16 +58,20 @@ foreach(target cuframes_composer cuframes_composer_static)
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${NVCODEC_HEADERS_DIR}
|
||||
)
|
||||
target_compile_features(${target} PRIVATE c_std_11)
|
||||
target_compile_features(${target} PRIVATE c_std_11 cxx_std_17)
|
||||
# C-only флаги (для CUDA свои дефолты, -Wpedantic не подходит для .cu).
|
||||
target_compile_options(${target} PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:C>:-Wall>
|
||||
$<$<COMPILE_LANGUAGE:C>:-Wextra>
|
||||
$<$<COMPILE_LANGUAGE:C>:-Wpedantic>
|
||||
$<$<COMPILE_LANGUAGE:CXX>:-Wall>
|
||||
$<$<COMPILE_LANGUAGE:CXX>:-Wextra>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:C>,$<CONFIG:Debug>>:-O0>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:C>,$<CONFIG:Debug>>:-g3>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:C>,$<CONFIG:Release>>:-O2>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:C>,$<CONFIG:Release>>:-g>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CONFIG:Release>>:-O2>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CONFIG:Release>>:-g>
|
||||
)
|
||||
target_link_libraries(${target}
|
||||
PUBLIC
|
||||
|
||||
-764
@@ -1,764 +0,0 @@
|
||||
/* Реализация cfc_composer_t — multi-source grid композитор.
|
||||
*
|
||||
* Owns:
|
||||
* - N cfc_source_t (по одному на ячейку grid'а)
|
||||
* - один NV12 output buffer (cuMemAlloc — staging для NVENC encoder'а)
|
||||
* - statistics для health-репортов
|
||||
*
|
||||
* Compose-цикл:
|
||||
* 1) cuMemsetD8 → быстрое черный fill всего Y plane (16=BT.709 black)
|
||||
* + UV plane заполняется отдельно (128,128).
|
||||
* 2) Для каждой ячейки:
|
||||
* a) get_latest snapshot.
|
||||
* b) ACTIVE → cfc_cugrid_resize_nv12 (src VMM → dst rect)
|
||||
* c) DEAD/STALE → cfc_cugrid_fill_nv12 чёрным с alpha=255 уже сделано,
|
||||
* тут лучше визуально показать что источник упал, поэтому в Phase 3
|
||||
* поверх blackout рисуется текст «NO SIGNAL» через overlay'и.
|
||||
* 3) cudaStreamSynchronize → output готов.
|
||||
*
|
||||
* Phase 2 упрощения:
|
||||
* - Sync compose на default stream. Stream pipelining — Phase 3+.
|
||||
* - Без double buffering. encode и compose делаются строго последовательно.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#include "../include/cuframes_composer/composer.h"
|
||||
#include "../include/cuframes_composer/cugrid.h"
|
||||
#include "../include/cuframes_composer/layouts.h"
|
||||
#include "../include/cuframes_composer/overlay.h"
|
||||
|
||||
#include <cuda_runtime.h>
|
||||
#include <pthread.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#define CFC_COMPOSER_MAX_CELLS 64
|
||||
#define CFC_COMPOSER_MAX_OVERLAYS 64
|
||||
|
||||
/* Source pool — для motion-mode. Каждая запись хранит cuframes-key,
|
||||
* привязку к Frigate-камере (для motion match'а) и приоритет. */
|
||||
#define CFC_POOL_ZONE_MAX 8
|
||||
#define CFC_POOL_ZONE_NAME 32
|
||||
typedef struct cfc_pool_entry {
|
||||
char cuframes_key[64];
|
||||
char frigate_camera[48];
|
||||
int priority;
|
||||
cfc_source_t *source;
|
||||
_Atomic int64_t last_motion_ms;
|
||||
/* Optional zone-filter. */
|
||||
char required_zones[CFC_POOL_ZONE_MAX][CFC_POOL_ZONE_NAME];
|
||||
int required_zones_count;
|
||||
/* Persistent text overlay "{key} prio={N}" — позиционируется в углу
|
||||
* cell при relayout, hidden если камера неактивна. */
|
||||
cfc_overlay_t *label_overlay;
|
||||
char label_text[96]; /* кеш строки для update_text без re-render */
|
||||
} cfc_pool_entry_t;
|
||||
|
||||
struct cfc_composer {
|
||||
cfc_composer_config_t cfg;
|
||||
|
||||
/* Копии cells (caller владеет original config'ом). source_key копируется
|
||||
* в персистентную строку чтобы cfc_source_t могла на неё указывать. */
|
||||
cfc_composer_cell_t cells[CFC_COMPOSER_MAX_CELLS];
|
||||
char cell_keys[CFC_COMPOSER_MAX_CELLS][64];
|
||||
int num_cells;
|
||||
|
||||
/* Источники теперь хранятся в pool, не привязаны к cells[].
|
||||
* compose_cell ищет source через pool_find_by_key(cell.source_key). */
|
||||
|
||||
/* Output NV12 буфер: один contiguous allocation, Y plane (pitch * h) +
|
||||
* UV plane (pitch * h/2). Pitch выравнен на 256 байт. */
|
||||
CUdeviceptr output_ptr;
|
||||
int output_pitch_y;
|
||||
int output_pitch_uv;
|
||||
size_t output_size;
|
||||
|
||||
/* CUDA stream для compose (Phase 2 — default stream = 0). */
|
||||
cudaStream_t stream;
|
||||
|
||||
/* Overlays — в порядке добавления (= z-order). composer take ownership. */
|
||||
cfc_overlay_t *overlays[CFC_COMPOSER_MAX_OVERLAYS];
|
||||
int num_overlays;
|
||||
|
||||
/* Текущий named layout (если был выставлен через set_layout). Пустая
|
||||
* строка = cells заданы вручную (через --cell). */
|
||||
char current_layout[CFC_LAYOUT_MAX_NAME];
|
||||
|
||||
/* Source pool — для motion-driven layout. Все cuframes-subscriptions
|
||||
* композитора живут здесь (включая те что добавились через --cell).
|
||||
* compose_cell ищет source по cuframes_key. */
|
||||
cfc_pool_entry_t pool[CFC_COMPOSER_MAX_CELLS];
|
||||
int pool_count;
|
||||
pthread_mutex_t pool_mu; /* для add_pool_source vs motion_pulse */
|
||||
|
||||
/* Motion-mode state. */
|
||||
int motion_mode; /* 0/1 */
|
||||
int motion_ttl_ms; /* default 45000 */
|
||||
};
|
||||
|
||||
static int64_t now_ms_mono(void)
|
||||
{
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
static void compose_motion_relayout(cfc_composer_t *comp);
|
||||
|
||||
/* Найти запись в pool по cuframes_key. NULL если нет. Caller держит mutex. */
|
||||
static cfc_pool_entry_t *pool_find_by_key(cfc_composer_t *comp, const char *key)
|
||||
{
|
||||
if (!key) return NULL;
|
||||
for (int i = 0; i < comp->pool_count; i++) {
|
||||
if (!strcmp(comp->pool[i].cuframes_key, key)) return &comp->pool[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────────────── */
|
||||
|
||||
static int round_up_pitch(int w)
|
||||
{
|
||||
return (w + 255) & ~255;
|
||||
}
|
||||
|
||||
static void *cu_ptr(CUdeviceptr p) { return (void *)(uintptr_t)p; }
|
||||
|
||||
/* ── Compose ──────────────────────────────────────────────────────────── */
|
||||
|
||||
static int compose_clear(cfc_composer_t *comp)
|
||||
{
|
||||
/* Y plane → 16 (BT.709 black). */
|
||||
cudaError_t e = cudaMemsetAsync(
|
||||
cu_ptr(comp->output_ptr), comp->cfg.bg_y,
|
||||
(size_t)comp->output_pitch_y * comp->cfg.height,
|
||||
comp->stream);
|
||||
if (e != cudaSuccess) {
|
||||
fprintf(stderr, "[cfc/composer] Y memset failed: %s\n", cudaGetErrorString(e));
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* UV plane → нужны два значения (U=128, V=128), не один. Делаем fill
|
||||
* через тот же cfc_cugrid_fill_nv12 которым fillим ячейки. Прокидываем
|
||||
* alpha=255 чтобы перезатереть полностью. */
|
||||
CUdeviceptr uv = comp->output_ptr +
|
||||
(size_t)comp->output_pitch_y * comp->cfg.height;
|
||||
|
||||
/* Просто memset UV не подходит — там interleaved пары. Делаем fill_nv12
|
||||
* с alpha=255, тогда формула станет dst = fill * 255 / 255 = fill. */
|
||||
int rc = cfc_cugrid_fill_nv12(
|
||||
(CUstream)comp->stream,
|
||||
/* Y уже сделан выше — но fill_nv12 повторно fillит Y. Передаём
|
||||
* y_color = bg_y, alpha=255 — get тот же результат, минор waste.
|
||||
* Phase 2 acceptable, в Phase 3 разделим Y/UV fillы. */
|
||||
comp->output_ptr, comp->output_pitch_y,
|
||||
uv, comp->output_pitch_uv,
|
||||
0, 0, comp->cfg.width, comp->cfg.height,
|
||||
comp->cfg.bg_y, comp->cfg.bg_u, comp->cfg.bg_v, 255);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int compose_cell(cfc_composer_t *comp, int idx)
|
||||
{
|
||||
const cfc_composer_cell_t *cell = &comp->cells[idx];
|
||||
/* Layout мог обнулить cell (set_layout с меньшим num_cells) — skip. */
|
||||
if (cell->w <= 0 || cell->h <= 0) return 0;
|
||||
if (!cell->source_key) return 0;
|
||||
/* Source lookup в pool — motion-mode перепривязывает cells на лету,
|
||||
* sources[idx] больше не валиден сам по себе. Lookup O(N), N ≤ 32. */
|
||||
cfc_pool_entry_t *p = pool_find_by_key(comp, cell->source_key);
|
||||
if (!p || !p->source) return 0;
|
||||
cfc_source_t *src = p->source;
|
||||
|
||||
cfc_source_snapshot_t snap;
|
||||
cfc_source_get_latest(src, &snap);
|
||||
|
||||
if (snap.state != CFC_SOURCE_ACTIVE || snap.width <= 0) {
|
||||
/* DEAD/STALE/CONNECTING — оставляем чёрный (уже clear'нут).
|
||||
* Phase 3 добавит overlay «NO SIGNAL». */
|
||||
return 0;
|
||||
}
|
||||
|
||||
CUdeviceptr uv = comp->output_ptr +
|
||||
(size_t)comp->output_pitch_y * comp->cfg.height;
|
||||
/* Source NV12 layout: Y (pitch_y * height) + UV (pitch_y * height/2)
|
||||
* непрерывно. Указатель на UV plane = ptr + pitch_y * height. */
|
||||
CUdeviceptr src_uv = snap.ptr + (size_t)snap.pitch_y * snap.height;
|
||||
|
||||
return cfc_cugrid_resize_nv12(
|
||||
(CUstream)comp->stream,
|
||||
snap.ptr, snap.width, snap.height, snap.pitch_y,
|
||||
src_uv, snap.pitch_uv,
|
||||
comp->output_ptr, comp->output_pitch_y,
|
||||
uv, comp->output_pitch_uv,
|
||||
cell->x, cell->y, cell->w, cell->h);
|
||||
}
|
||||
|
||||
/* ── Public API ───────────────────────────────────────────────────────── */
|
||||
|
||||
int cfc_composer_create(const cfc_composer_config_t *cfg, cfc_composer_t **out)
|
||||
{
|
||||
if (!cfg || !out) return -1;
|
||||
if (cfg->width <= 0 || cfg->height <= 0) return -1;
|
||||
if (cfg->num_cells <= 0 || cfg->num_cells > CFC_COMPOSER_MAX_CELLS) return -1;
|
||||
if (!cfg->cells) return -1;
|
||||
|
||||
cfc_composer_t *comp = calloc(1, sizeof(*comp));
|
||||
if (!comp) return -1;
|
||||
comp->cfg = *cfg;
|
||||
comp->num_cells = cfg->num_cells;
|
||||
comp->stream = 0; /* default stream Phase 2 */
|
||||
pthread_mutex_init(&comp->pool_mu, NULL);
|
||||
comp->motion_ttl_ms = 45000; /* default 45s (рабочий sweet spot 30-60) */
|
||||
|
||||
/* Дефолты для bg цвета (если caller не задал). */
|
||||
if (!comp->cfg.bg_y) comp->cfg.bg_y = 16;
|
||||
if (!comp->cfg.bg_u) comp->cfg.bg_u = 128;
|
||||
if (!comp->cfg.bg_v) comp->cfg.bg_v = 128;
|
||||
|
||||
/* Сохраняем cells + копируем source_key в персистентное хранилище. */
|
||||
for (int i = 0; i < cfg->num_cells; i++) {
|
||||
comp->cells[i] = cfg->cells[i];
|
||||
if (cfg->cells[i].source_key) {
|
||||
strncpy(comp->cell_keys[i], cfg->cells[i].source_key,
|
||||
sizeof(comp->cell_keys[i]) - 1);
|
||||
comp->cells[i].source_key = comp->cell_keys[i];
|
||||
}
|
||||
}
|
||||
|
||||
/* Выделяем output NV12 буфер. */
|
||||
comp->output_pitch_y = round_up_pitch(cfg->width);
|
||||
comp->output_pitch_uv = comp->output_pitch_y;
|
||||
comp->output_size = (size_t)comp->output_pitch_y * cfg->height +
|
||||
(size_t)comp->output_pitch_uv * (cfg->height / 2);
|
||||
|
||||
CUresult cr = cuMemAlloc(&comp->output_ptr, comp->output_size);
|
||||
if (cr != CUDA_SUCCESS) {
|
||||
const char *es = NULL; cuGetErrorString(cr, &es);
|
||||
fprintf(stderr, "[cfc/composer] cuMemAlloc(%zu) failed: %s\n",
|
||||
comp->output_size, es ? es : "?");
|
||||
free(comp);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Pool: для каждой уникальной cell.source_key создаём подписку.
|
||||
* Если key уже в pool (тот же source в нескольких cells) — реюзим. */
|
||||
for (int i = 0; i < comp->num_cells; i++) {
|
||||
const char *key = comp->cells[i].source_key;
|
||||
if (!key) continue;
|
||||
if (pool_find_by_key(comp, key)) continue; /* уже добавлен */
|
||||
if (comp->pool_count >= CFC_COMPOSER_MAX_CELLS) {
|
||||
fprintf(stderr, "[cfc/composer] pool overflow\n");
|
||||
break;
|
||||
}
|
||||
cfc_pool_entry_t *e = &comp->pool[comp->pool_count];
|
||||
strncpy(e->cuframes_key, key, sizeof(e->cuframes_key) - 1);
|
||||
e->cuframes_key[sizeof(e->cuframes_key) - 1] = '\0';
|
||||
e->priority = 0;
|
||||
atomic_init(&e->last_motion_ms, 0);
|
||||
|
||||
char name[32];
|
||||
const char *prefix = comp->cfg.consumer_prefix;
|
||||
if (!prefix || !*prefix) prefix = "composer";
|
||||
snprintf(name, sizeof(name), "%s-%d", prefix, comp->pool_count);
|
||||
cfc_source_config_t scfg = {
|
||||
.key = key,
|
||||
.consumer_name = name,
|
||||
.cuda_device = cfg->cuda_device,
|
||||
.reconnect_min_ms = cfg->reconnect_min_ms,
|
||||
.reconnect_max_ms = cfg->reconnect_max_ms,
|
||||
.stale_threshold_ms = cfg->stale_threshold_ms,
|
||||
.dead_threshold_ms = cfg->dead_threshold_ms,
|
||||
};
|
||||
if (cfc_source_create(&scfg, &e->source) != 0) {
|
||||
fprintf(stderr,
|
||||
"[cfc/composer] cfc_source_create failed для '%s' (pool[%d])\n",
|
||||
key, comp->pool_count);
|
||||
e->source = NULL; /* DEAD — compose покажет blackout */
|
||||
}
|
||||
comp->pool_count++;
|
||||
}
|
||||
|
||||
cfc_cugrid_init();
|
||||
|
||||
*out = comp;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_compose(cfc_composer_t *comp,
|
||||
CUdeviceptr *out_y_ptr,
|
||||
int *out_pitch_y,
|
||||
int *out_width,
|
||||
int *out_height)
|
||||
{
|
||||
if (!comp) return -1;
|
||||
|
||||
/* Motion-mode пересобирает cells перед каждым кадром (no-op если выключен). */
|
||||
compose_motion_relayout(comp);
|
||||
|
||||
if (compose_clear(comp) != 0) return -1;
|
||||
|
||||
for (int i = 0; i < comp->num_cells; i++) {
|
||||
if (compose_cell(comp, i) != 0) {
|
||||
fprintf(stderr, "[cfc/composer] compose_cell %d failed\n", i);
|
||||
/* Не fatal — продолжаем с остальными ячейками. */
|
||||
}
|
||||
}
|
||||
|
||||
/* Overlays поверх grid'а — в порядке добавления. */
|
||||
CUdeviceptr uv = comp->output_ptr +
|
||||
(size_t)comp->output_pitch_y * comp->cfg.height;
|
||||
for (int i = 0; i < comp->num_overlays; i++) {
|
||||
if (cfc_overlay_draw(comp->overlays[i],
|
||||
(CUstream)comp->stream,
|
||||
comp->output_ptr, comp->output_pitch_y,
|
||||
uv, comp->output_pitch_uv,
|
||||
comp->cfg.width, comp->cfg.height) != 0) {
|
||||
fprintf(stderr, "[cfc/composer] overlay %d draw failed\n", i);
|
||||
/* Не fatal. */
|
||||
}
|
||||
}
|
||||
|
||||
cudaError_t e = cudaStreamSynchronize(comp->stream);
|
||||
if (e != cudaSuccess) {
|
||||
fprintf(stderr, "[cfc/composer] stream sync failed: %s\n",
|
||||
cudaGetErrorString(e));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (out_y_ptr) *out_y_ptr = comp->output_ptr;
|
||||
if (out_pitch_y) *out_pitch_y = comp->output_pitch_y;
|
||||
if (out_width) *out_width = comp->cfg.width;
|
||||
if (out_height) *out_height = comp->cfg.height;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_add_overlay(cfc_composer_t *comp, cfc_overlay_t *ov)
|
||||
{
|
||||
if (!comp || !ov) return -1;
|
||||
if (comp->num_overlays >= CFC_COMPOSER_MAX_OVERLAYS) {
|
||||
fprintf(stderr, "[cfc/composer] overlay limit %d reached\n",
|
||||
CFC_COMPOSER_MAX_OVERLAYS);
|
||||
return -1;
|
||||
}
|
||||
comp->overlays[comp->num_overlays++] = ov;
|
||||
return 0;
|
||||
}
|
||||
|
||||
cfc_overlay_t *cfc_composer_find_overlay(cfc_composer_t *comp, const char *id)
|
||||
{
|
||||
if (!comp || !id) return NULL;
|
||||
for (int i = 0; i < comp->num_overlays; i++) {
|
||||
const char *oid = cfc_overlay_get_id(comp->overlays[i]);
|
||||
if (oid && !strcmp(oid, id)) return comp->overlays[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int cfc_composer_set_layout(cfc_composer_t *comp, const char *layout_name)
|
||||
{
|
||||
if (!comp || !layout_name) return -1;
|
||||
/* В motion-mode set_layout игнорируется — relayout управляется
|
||||
* автоматически из compose_motion_relayout. */
|
||||
if (comp->motion_mode) {
|
||||
fprintf(stderr, "[cfc/composer] set_layout('%s') ignored: motion_mode active\n",
|
||||
layout_name);
|
||||
return -1;
|
||||
}
|
||||
const cfc_layout_t *lay = cfc_layout_find(layout_name);
|
||||
if (!lay) {
|
||||
fprintf(stderr, "[cfc/composer] unknown layout '%s'\n", layout_name);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int W = comp->cfg.width, H = comp->cfg.height;
|
||||
int n_apply = lay->nb_cells;
|
||||
if (n_apply > CFC_COMPOSER_MAX_CELLS) n_apply = CFC_COMPOSER_MAX_CELLS;
|
||||
if (n_apply > comp->num_cells) n_apply = comp->num_cells;
|
||||
/* Микро-сетка → pixel coords для каждой cell. Source_key привязок не
|
||||
* меняем (Step 2+ добавит role/order распределение). */
|
||||
for (int i = 0; i < n_apply; i++) {
|
||||
int x, y, w, h;
|
||||
cfc_layout_to_pixels(&lay->cells[i], W, H, &x, &y, &w, &h);
|
||||
comp->cells[i].x = x;
|
||||
comp->cells[i].y = y;
|
||||
comp->cells[i].w = w;
|
||||
comp->cells[i].h = h;
|
||||
}
|
||||
/* Cells сверх layout->nb_cells — обнуляем, чтобы не рисовались. */
|
||||
for (int i = n_apply; i < comp->num_cells; i++) {
|
||||
comp->cells[i].w = 0;
|
||||
comp->cells[i].h = 0;
|
||||
}
|
||||
|
||||
strncpy(comp->current_layout, lay->name, sizeof(comp->current_layout) - 1);
|
||||
comp->current_layout[sizeof(comp->current_layout) - 1] = '\0';
|
||||
fprintf(stderr, "[cfc/composer] layout='%s' (%d active cells, %d sources)\n",
|
||||
lay->name, n_apply, comp->num_cells);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *cfc_composer_current_layout(cfc_composer_t *comp)
|
||||
{
|
||||
if (!comp) return NULL;
|
||||
return comp->current_layout[0] ? comp->current_layout : NULL;
|
||||
}
|
||||
|
||||
/* ── Motion-driven layout ────────────────────────────────────────────── */
|
||||
|
||||
/* Распарсить colon-separated zones list в entry. */
|
||||
static void parse_zones(cfc_pool_entry_t *e, const char *zones)
|
||||
{
|
||||
e->required_zones_count = 0;
|
||||
if (!zones || !*zones) return;
|
||||
const char *p = zones;
|
||||
while (p && *p && e->required_zones_count < CFC_POOL_ZONE_MAX) {
|
||||
const char *sep = strchr(p, ':');
|
||||
int len = sep ? (int)(sep - p) : (int)strlen(p);
|
||||
if (len > CFC_POOL_ZONE_NAME - 1) len = CFC_POOL_ZONE_NAME - 1;
|
||||
memcpy(e->required_zones[e->required_zones_count], p, len);
|
||||
e->required_zones[e->required_zones_count][len] = '\0';
|
||||
e->required_zones_count++;
|
||||
p = sep ? sep + 1 : NULL;
|
||||
}
|
||||
}
|
||||
|
||||
int cfc_composer_add_pool_source(cfc_composer_t *comp,
|
||||
const char *cuframes_key,
|
||||
const char *frigate_camera,
|
||||
int priority,
|
||||
const char *required_zones)
|
||||
{
|
||||
if (!comp || !cuframes_key) return -1;
|
||||
pthread_mutex_lock(&comp->pool_mu);
|
||||
|
||||
cfc_pool_entry_t *e = pool_find_by_key(comp, cuframes_key);
|
||||
if (e) {
|
||||
/* Уже в pool (был добавлен из --cell). Просто перебиваем
|
||||
* frigate_camera + priority + zones. */
|
||||
if (frigate_camera) {
|
||||
strncpy(e->frigate_camera, frigate_camera, sizeof(e->frigate_camera) - 1);
|
||||
e->frigate_camera[sizeof(e->frigate_camera) - 1] = '\0';
|
||||
}
|
||||
e->priority = priority;
|
||||
parse_zones(e, required_zones);
|
||||
pthread_mutex_unlock(&comp->pool_mu);
|
||||
return 0;
|
||||
}
|
||||
if (comp->pool_count >= CFC_COMPOSER_MAX_CELLS) {
|
||||
pthread_mutex_unlock(&comp->pool_mu);
|
||||
return -1;
|
||||
}
|
||||
e = &comp->pool[comp->pool_count];
|
||||
strncpy(e->cuframes_key, cuframes_key, sizeof(e->cuframes_key) - 1);
|
||||
e->cuframes_key[sizeof(e->cuframes_key) - 1] = '\0';
|
||||
if (frigate_camera) {
|
||||
strncpy(e->frigate_camera, frigate_camera, sizeof(e->frigate_camera) - 1);
|
||||
e->frigate_camera[sizeof(e->frigate_camera) - 1] = '\0';
|
||||
}
|
||||
e->priority = priority;
|
||||
parse_zones(e, required_zones);
|
||||
atomic_init(&e->last_motion_ms, 0);
|
||||
|
||||
char name[32];
|
||||
const char *prefix = comp->cfg.consumer_prefix;
|
||||
if (!prefix || !*prefix) prefix = "composer";
|
||||
snprintf(name, sizeof(name), "%s-%d", prefix, comp->pool_count);
|
||||
cfc_source_config_t scfg = {
|
||||
.key = e->cuframes_key,
|
||||
.consumer_name = name,
|
||||
.cuda_device = comp->cfg.cuda_device,
|
||||
.reconnect_min_ms = comp->cfg.reconnect_min_ms,
|
||||
.reconnect_max_ms = comp->cfg.reconnect_max_ms,
|
||||
.stale_threshold_ms = comp->cfg.stale_threshold_ms,
|
||||
.dead_threshold_ms = comp->cfg.dead_threshold_ms,
|
||||
};
|
||||
if (cfc_source_create(&scfg, &e->source) != 0) {
|
||||
fprintf(stderr, "[cfc/composer] add_pool_source: subscribe '%s' failed\n",
|
||||
cuframes_key);
|
||||
e->source = NULL;
|
||||
}
|
||||
|
||||
/* Persistent text overlay для подписи cell — позиция выставляется в
|
||||
* compose_motion_relayout (visible=1 + x/y); неактивные камеры — visible=0.
|
||||
* Font hardcoded под production volume `/fonts/DejaVuSans-Bold.ttf`. */
|
||||
snprintf(e->label_text, sizeof(e->label_text), "%s prio=%d", cuframes_key, priority);
|
||||
cfc_overlay_text_config_t tc = {
|
||||
.font_path = "/fonts/DejaVuSans-Bold.ttf",
|
||||
.text = e->label_text,
|
||||
.pixel_size = 22,
|
||||
.x = 0, .y = 0,
|
||||
.r = 255, .g = 220, .b = 64, /* жёлто-оранжевый: читается на любом фоне */
|
||||
.extra_alpha = 255,
|
||||
.visible = 0,
|
||||
};
|
||||
if (cfc_overlay_create_text(&tc, &e->label_overlay) == 0) {
|
||||
cfc_composer_add_overlay(comp, e->label_overlay);
|
||||
} else {
|
||||
fprintf(stderr, "[cfc/composer] label overlay для '%s' не создан\n", cuframes_key);
|
||||
e->label_overlay = NULL;
|
||||
}
|
||||
|
||||
comp->pool_count++;
|
||||
pthread_mutex_unlock(&comp->pool_mu);
|
||||
fprintf(stderr, "[cfc/composer] pool+ '%s' (frigate=%s prio=%d) total=%d\n",
|
||||
cuframes_key, frigate_camera ? frigate_camera : "-",
|
||||
priority, comp->pool_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_set_motion_mode(cfc_composer_t *comp, int on, int ttl_ms)
|
||||
{
|
||||
if (!comp) return -1;
|
||||
comp->motion_mode = on ? 1 : 0;
|
||||
if (ttl_ms > 0) comp->motion_ttl_ms = ttl_ms;
|
||||
fprintf(stderr, "[cfc/composer] motion_mode=%d ttl=%dms pool=%d\n",
|
||||
comp->motion_mode, comp->motion_ttl_ms, comp->pool_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_get_motion_mode(cfc_composer_t *comp)
|
||||
{
|
||||
return comp ? comp->motion_mode : 0;
|
||||
}
|
||||
|
||||
/* Сверить current_zones с required_zones записи pool'а. */
|
||||
static int zones_match(const cfc_pool_entry_t *e,
|
||||
const char *const *current_zones, int n)
|
||||
{
|
||||
if (e->required_zones_count == 0) return 1; /* фильтр выключен */
|
||||
if (n <= 0 || !current_zones) return 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (!current_zones[i]) continue;
|
||||
for (int j = 0; j < e->required_zones_count; j++) {
|
||||
if (!strcmp(current_zones[i], e->required_zones[j])) return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_motion_pulse(cfc_composer_t *comp,
|
||||
const char *frigate_camera,
|
||||
const char *const *current_zones,
|
||||
int n_zones)
|
||||
{
|
||||
if (!comp || !frigate_camera) return -1;
|
||||
int found = 0;
|
||||
pthread_mutex_lock(&comp->pool_mu);
|
||||
int64_t now = now_ms_mono();
|
||||
for (int i = 0; i < comp->pool_count; i++) {
|
||||
if (!comp->pool[i].frigate_camera[0]) continue;
|
||||
if (strcmp(comp->pool[i].frigate_camera, frigate_camera) != 0) continue;
|
||||
if (!zones_match(&comp->pool[i], current_zones, n_zones)) continue;
|
||||
atomic_store(&comp->pool[i].last_motion_ms, now);
|
||||
found = 1;
|
||||
}
|
||||
pthread_mutex_unlock(&comp->pool_mu);
|
||||
return found ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Best-fit selection: минимальный template c nb_camera_cells >= need.
|
||||
* При ties побеждает выше priority. Если ничего не подходит — самый большой. */
|
||||
static const cfc_layout_t *pick_best_fit(int need)
|
||||
{
|
||||
int n_layouts = 0;
|
||||
const cfc_layout_t *all = cfc_layout_all(&n_layouts);
|
||||
if (n_layouts == 0) return NULL;
|
||||
|
||||
const cfc_layout_t *best = NULL;
|
||||
int best_waste = -1;
|
||||
int best_prio = -1;
|
||||
for (int i = 0; i < n_layouts; i++) {
|
||||
const cfc_layout_t *l = &all[i];
|
||||
if (l->nb_camera_cells < need) continue;
|
||||
int waste = l->nb_camera_cells - need;
|
||||
if (best == NULL || waste < best_waste ||
|
||||
(waste == best_waste && l->priority > best_prio)) {
|
||||
best = l;
|
||||
best_waste = waste;
|
||||
best_prio = l->priority;
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
|
||||
/* Overflow: ни один не подходит. Берём с max nb_camera_cells. */
|
||||
best = &all[0];
|
||||
for (int i = 1; i < n_layouts; i++) {
|
||||
if (all[i].nb_camera_cells > best->nb_camera_cells) best = &all[i];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/* Motion-mode relayout — Phase 11 Step 2.
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. active = {pool[i] : (now - last_motion_ms) < ttl}
|
||||
* 2. sort active by priority DESC
|
||||
* 3. template = pick_best_fit(|active|)
|
||||
* 4. распределить active по template.cells[role=CAMERA] в порядке order ASC
|
||||
* 5. лишние cells.w = 0
|
||||
* 6. idle (|active|=0): tpl_1 + top-priority из pool
|
||||
*
|
||||
* Hysteresis, DEAD-exclusion и widget rendering — следующие step'ы. */
|
||||
static void compose_motion_relayout(cfc_composer_t *comp)
|
||||
{
|
||||
if (!comp->motion_mode) return;
|
||||
if (comp->pool_count == 0) return;
|
||||
|
||||
int64_t now = now_ms_mono();
|
||||
int active_idx[CFC_COMPOSER_MAX_CELLS];
|
||||
int active_prio[CFC_COMPOSER_MAX_CELLS];
|
||||
int n_active = 0;
|
||||
|
||||
pthread_mutex_lock(&comp->pool_mu);
|
||||
for (int i = 0; i < comp->pool_count; i++) {
|
||||
int64_t last = atomic_load(&comp->pool[i].last_motion_ms);
|
||||
if (last == 0) continue;
|
||||
if (now - last > comp->motion_ttl_ms) continue;
|
||||
active_idx[n_active] = i;
|
||||
active_prio[n_active] = comp->pool[i].priority;
|
||||
n_active++;
|
||||
}
|
||||
|
||||
/* Idle: 0 active → tpl_1 + top-priority pool entry. */
|
||||
if (n_active == 0) {
|
||||
int best = 0;
|
||||
for (int i = 1; i < comp->pool_count; i++) {
|
||||
if (comp->pool[i].priority > comp->pool[best].priority) best = i;
|
||||
}
|
||||
active_idx[0] = best;
|
||||
active_prio[0] = comp->pool[best].priority;
|
||||
n_active = 1;
|
||||
}
|
||||
pthread_mutex_unlock(&comp->pool_mu);
|
||||
|
||||
/* Insertion sort by priority DESC (stable). */
|
||||
for (int i = 1; i < n_active; i++) {
|
||||
int ki = active_idx[i], kp = active_prio[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && active_prio[j] < kp) {
|
||||
active_idx[j + 1] = active_idx[j];
|
||||
active_prio[j + 1] = active_prio[j];
|
||||
j--;
|
||||
}
|
||||
active_idx[j + 1] = ki;
|
||||
active_prio[j + 1] = kp;
|
||||
}
|
||||
|
||||
const cfc_layout_t *lay = pick_best_fit(n_active);
|
||||
if (!lay) return;
|
||||
|
||||
/* Если active > template — обрезаем (lowest-priority вылетают). */
|
||||
int slots = lay->nb_camera_cells;
|
||||
if (n_active > slots) n_active = slots;
|
||||
|
||||
/* Сначала спрятать все labels — активные включим ниже. */
|
||||
for (int i = 0; i < comp->pool_count; i++) {
|
||||
if (!comp->pool[i].label_overlay) continue;
|
||||
cfc_overlay_text_config_t hide = {
|
||||
.font_path = "/fonts/DejaVuSans-Bold.ttf",
|
||||
.text = comp->pool[i].label_text, /* тот же текст — без re-render */
|
||||
.pixel_size = 22,
|
||||
.r = 255, .g = 220, .b = 64,
|
||||
.extra_alpha = 255, .visible = 0,
|
||||
};
|
||||
cfc_overlay_update_text(comp->pool[i].label_overlay, &hide);
|
||||
}
|
||||
|
||||
/* Распределить активные по camera-cells (в порядке order ASC). */
|
||||
int W = comp->cfg.width, H = comp->cfg.height;
|
||||
int placed = 0;
|
||||
for (int order = 0; order < slots && placed < n_active; order++) {
|
||||
for (int i = 0; i < lay->nb_cells; i++) {
|
||||
const cfc_cell_t *c = &lay->cells[i];
|
||||
if (c->role != CFC_CELL_CAMERA) continue;
|
||||
if (c->order != order) continue;
|
||||
|
||||
int x, y, w, h;
|
||||
cfc_layout_to_pixels(c, W, H, &x, &y, &w, &h);
|
||||
comp->cells[placed].source_key = comp->pool[active_idx[placed]].cuframes_key;
|
||||
comp->cells[placed].x = x; comp->cells[placed].y = y;
|
||||
comp->cells[placed].w = w; comp->cells[placed].h = h;
|
||||
|
||||
/* Включить label в левом-верхнем углу cell. */
|
||||
cfc_pool_entry_t *pe = &comp->pool[active_idx[placed]];
|
||||
if (pe->label_overlay) {
|
||||
cfc_overlay_text_config_t show = {
|
||||
.font_path = "/fonts/DejaVuSans-Bold.ttf",
|
||||
.text = pe->label_text, /* тот же текст — без re-render */
|
||||
.pixel_size = 22,
|
||||
.x = x + 10, .y = y + 8,
|
||||
.r = 255, .g = 220, .b = 64,
|
||||
.extra_alpha = 255, .visible = 1,
|
||||
};
|
||||
cfc_overlay_update_text(pe->label_overlay, &show);
|
||||
}
|
||||
placed++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
/* Лишние cells composer'а (за пределами placed) — обнуляем. */
|
||||
for (int i = placed; i < CFC_COMPOSER_MAX_CELLS; i++) {
|
||||
comp->cells[i].w = 0;
|
||||
comp->cells[i].h = 0;
|
||||
}
|
||||
comp->num_cells = placed;
|
||||
|
||||
/* Сигнатура для лога — меняется при смене template или active set. */
|
||||
static char last_signature[256];
|
||||
char sig[256];
|
||||
int off = snprintf(sig, sizeof(sig), "%s|", lay->name);
|
||||
for (int i = 0; i < placed && off < (int)sizeof(sig) - 1; i++) {
|
||||
int n = snprintf(sig + off, sizeof(sig) - off, "%s,",
|
||||
comp->pool[active_idx[i]].cuframes_key);
|
||||
if (n <= 0) break;
|
||||
off += n;
|
||||
}
|
||||
if (strcmp(sig, last_signature) != 0) {
|
||||
strncpy(last_signature, sig, sizeof(last_signature) - 1);
|
||||
last_signature[sizeof(last_signature) - 1] = '\0';
|
||||
fprintf(stderr, "[cfc/composer] motion-template='%s' active=%d : %s\n",
|
||||
lay->name, placed, sig);
|
||||
}
|
||||
}
|
||||
|
||||
int cfc_composer_get_health(cfc_composer_t *comp, cfc_composer_health_t *out)
|
||||
{
|
||||
if (!comp || !out) return -1;
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->total = comp->pool_count;
|
||||
for (int i = 0; i < comp->pool_count; i++) {
|
||||
if (!comp->pool[i].source) {
|
||||
out->dead++;
|
||||
continue;
|
||||
}
|
||||
cfc_source_snapshot_t snap;
|
||||
cfc_source_get_latest(comp->pool[i].source, &snap);
|
||||
switch (snap.state) {
|
||||
case CFC_SOURCE_ACTIVE: out->active++; break;
|
||||
case CFC_SOURCE_STALE: out->stale++; break;
|
||||
default: out->dead++; break;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_destroy(cfc_composer_t *comp)
|
||||
{
|
||||
if (!comp) return 0;
|
||||
for (int i = 0; i < comp->pool_count; i++) {
|
||||
if (comp->pool[i].source) cfc_source_destroy(comp->pool[i].source);
|
||||
}
|
||||
for (int i = 0; i < comp->num_overlays; i++) {
|
||||
cfc_overlay_destroy(comp->overlays[i]);
|
||||
}
|
||||
pthread_mutex_destroy(&comp->pool_mu);
|
||||
if (comp->output_ptr) cuMemFree(comp->output_ptr);
|
||||
free(comp);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* BlankCell — реализация. Чёрный fill в свою геометрию. */
|
||||
|
||||
#include "../../include/cuframes_composer/cpp/blank_cell.hpp"
|
||||
#include "../../include/cuframes_composer/cugrid.h"
|
||||
|
||||
namespace cfc {
|
||||
|
||||
void BlankCell::draw_content(CUstream stream, NV12Ref& dst)
|
||||
{
|
||||
if (geom_.empty()) return;
|
||||
cfc_cugrid_fill_nv12(
|
||||
stream,
|
||||
dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
geom_.x, geom_.y, geom_.w, geom_.h,
|
||||
16, 128, 128, 255); /* BT.709 black */
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
@@ -0,0 +1,50 @@
|
||||
/* BorderDecoration — реализация (Phase 11b).
|
||||
*
|
||||
* 4 calls cfc_cugrid_fill_nv12 для top/bottom/left/right полос. Координаты
|
||||
* выравниваются на чётные (NV12 requirement).
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#include "../../include/cuframes_composer/cpp/border_decoration.hpp"
|
||||
#include "../../include/cuframes_composer/cugrid.h"
|
||||
|
||||
namespace cfc {
|
||||
|
||||
void BorderDecoration::draw(CUstream stream, NV12Ref& dst, const Rect& p)
|
||||
{
|
||||
if (!style_.visible || style_.alpha <= 0 || style_.thickness <= 0) return;
|
||||
if (p.empty()) return;
|
||||
|
||||
int x = p.x, y = p.y, w = p.w, h = p.h;
|
||||
int t = style_.thickness;
|
||||
|
||||
if (x < 0) { w += x; x = 0; }
|
||||
if (y < 0) { h += y; y = 0; }
|
||||
if (x + w > dst.frame_w) w = dst.frame_w - x;
|
||||
if (y + h > dst.frame_h) h = dst.frame_h - y;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
if (t * 2 > w) t = w / 2;
|
||||
if (t * 2 > h) t = h / 2;
|
||||
x &= ~1; y &= ~1; w &= ~1; h &= ~1; t &= ~1;
|
||||
if (t == 0) t = 2;
|
||||
|
||||
/* top */
|
||||
cfc_cugrid_fill_nv12(stream, dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
x, y, w, t,
|
||||
style_.color_y, style_.color_u, style_.color_v, style_.alpha);
|
||||
/* bottom */
|
||||
cfc_cugrid_fill_nv12(stream, dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
x, y + h - t, w, t,
|
||||
style_.color_y, style_.color_u, style_.color_v, style_.alpha);
|
||||
/* left */
|
||||
cfc_cugrid_fill_nv12(stream, dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
x, y + t, t, h - 2 * t,
|
||||
style_.color_y, style_.color_u, style_.color_v, style_.alpha);
|
||||
/* right */
|
||||
cfc_cugrid_fill_nv12(stream, dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
x + w - t, y + t, t, h - 2 * t,
|
||||
style_.color_y, style_.color_u, style_.color_v, style_.alpha);
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
@@ -0,0 +1,33 @@
|
||||
/* CameraCell — реализация (Phase 11b). */
|
||||
|
||||
#include "../../include/cuframes_composer/cpp/camera_cell.hpp"
|
||||
#include "../../include/cuframes_composer/cugrid.h"
|
||||
|
||||
namespace cfc {
|
||||
|
||||
void CameraCell::draw_content(CUstream stream, NV12Ref& dst)
|
||||
{
|
||||
if (!source_) return;
|
||||
if (geom_.empty()) return;
|
||||
|
||||
cfc_source_snapshot_t snap{};
|
||||
cfc_source_get_latest(source_, &snap);
|
||||
if (snap.state != CFC_SOURCE_ACTIVE && snap.state != CFC_SOURCE_STALE) {
|
||||
return; /* CONNECTING/DEAD/DISCONNECTED — blackout (clear был раньше) */
|
||||
}
|
||||
if (snap.width <= 0 || snap.height <= 0) return;
|
||||
|
||||
/* Source NV12 layout: Y plane (pitch_y * height) + UV (pitch_y * height/2)
|
||||
* непрерывно. UV plane = ptr + pitch_y * height. */
|
||||
CUdeviceptr src_uv = snap.ptr + static_cast<std::size_t>(snap.pitch_y) * snap.height;
|
||||
|
||||
cfc_cugrid_resize_nv12(
|
||||
stream,
|
||||
snap.ptr, snap.width, snap.height, snap.pitch_y,
|
||||
src_uv, snap.pitch_uv,
|
||||
dst.y_ptr, dst.pitch_y,
|
||||
dst.uv_ptr, dst.pitch_uv,
|
||||
geom_.x, geom_.y, geom_.w, geom_.h);
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
@@ -0,0 +1,392 @@
|
||||
/* Composer — реализация (Phase 11b). */
|
||||
|
||||
#include "../../include/cuframes_composer/cpp/composer.hpp"
|
||||
#include "../../include/cuframes_composer/cpp/blank_cell.hpp"
|
||||
#include "../../include/cuframes_composer/cpp/template_loader.hpp"
|
||||
#include "../../include/cuframes_composer/cugrid.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
static std::int64_t now_ms_mono()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
static int round_up_pitch(int w) { return (w + 255) & ~255; }
|
||||
|
||||
Composer::Composer(const ComposerConfig& cfg) : cfg_(cfg)
|
||||
{
|
||||
pitch_y_ = round_up_pitch(cfg_.width);
|
||||
pitch_uv_ = pitch_y_;
|
||||
std::size_t size = static_cast<std::size_t>(pitch_y_) * cfg_.height +
|
||||
static_cast<std::size_t>(pitch_uv_) * (cfg_.height / 2);
|
||||
output_ = CudaBuffer(size);
|
||||
if (!output_.ok()) {
|
||||
std::fprintf(stderr, "[cfc/composer] cuMemAlloc %zu failed\n", size);
|
||||
return;
|
||||
}
|
||||
|
||||
cfc_cugrid_init();
|
||||
|
||||
/* Templates: грузим через глобальный registry, чтобы hot-reload через
|
||||
* ABI shim (cfc_layout_load_file из любого треда) был виден компосеру
|
||||
* на следующем кадре. */
|
||||
if (!cfg_.templates_path.empty()) {
|
||||
load_into_current(cfg_.templates_path);
|
||||
}
|
||||
/* В Composer держим только snapshot — реальный source истины =
|
||||
* current_templates(). Снимок обновляется в pick_best_fit на лету. */
|
||||
templates_ = current_templates();
|
||||
std::fprintf(stderr, "[cfc/composer] templates loaded: %zu (path='%s')\n",
|
||||
templates_.size(), cfg_.templates_path.c_str());
|
||||
}
|
||||
|
||||
Composer::~Composer()
|
||||
{
|
||||
for (auto* ov : overlays_) {
|
||||
if (ov) cfc_overlay_destroy(ov);
|
||||
}
|
||||
}
|
||||
|
||||
int Composer::add_overlay(cfc_overlay_t* ov)
|
||||
{
|
||||
if (!ov) return -1;
|
||||
overlays_.push_back(ov);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cfc_overlay_t* Composer::find_overlay(const std::string& id) const
|
||||
{
|
||||
for (auto* ov : overlays_) {
|
||||
const char* oid = cfc_overlay_get_id(ov);
|
||||
if (oid && id == oid) return ov;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Composer::Health Composer::get_health() const
|
||||
{
|
||||
Health h{};
|
||||
auto& pool_ref = const_cast<SourcePool&>(pool_);
|
||||
pool_ref.for_each([&](PoolEntry& e) {
|
||||
h.total++;
|
||||
cfc_source_state_t st = e.state();
|
||||
switch (st) {
|
||||
case CFC_SOURCE_ACTIVE: h.active++; break;
|
||||
case CFC_SOURCE_STALE: h.stale++; break;
|
||||
default: h.dead++; break;
|
||||
}
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
void Composer::set_manual_cells(const std::vector<std::pair<std::string, Rect>>& cells)
|
||||
{
|
||||
manual_cells_ = cells;
|
||||
manual_applied_ = false; /* compose_frame применит */
|
||||
}
|
||||
|
||||
int Composer::load_templates(const std::string& path)
|
||||
{
|
||||
int r = load_into_current(path);
|
||||
if (r > 0) {
|
||||
templates_ = current_templates();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
void Composer::set_motion_mode(bool on, int ttl_ms)
|
||||
{
|
||||
motion_mode_ = on;
|
||||
if (ttl_ms > 0) cfg_.motion_ttl_ms = ttl_ms;
|
||||
/* invalidate signature чтобы relayout пересчитался. */
|
||||
committed_signature_.clear();
|
||||
pending_signature_.clear();
|
||||
std::fprintf(stderr, "[cfc/composer] motion_mode=%d ttl=%dms pool=%d\n",
|
||||
motion_mode_ ? 1 : 0, cfg_.motion_ttl_ms, pool_.size());
|
||||
}
|
||||
|
||||
bool Composer::set_layout(const std::string& name)
|
||||
{
|
||||
/* В motion-mode set_layout не игнорируется: применяем + freezing motion
|
||||
* на manual_override_duration_ms_ (default 60s). После — auto возврат. */
|
||||
const auto& reg = current_templates();
|
||||
auto it = std::find_if(reg.begin(), reg.end(),
|
||||
[&](const LayoutTemplate& t) { return t.name == name; });
|
||||
if (it == reg.end()) {
|
||||
std::fprintf(stderr, "[cfc/composer] unknown template '%s'\n", name.c_str());
|
||||
return false;
|
||||
}
|
||||
/* Manual mode: всех в pool по priority — не motion-based. */
|
||||
std::vector<PoolEntry*> snap;
|
||||
pool_.for_each([&](PoolEntry& e) { snap.push_back(&e); });
|
||||
std::sort(snap.begin(), snap.end(),
|
||||
[](PoolEntry* a, PoolEntry* b) { return a->priority > b->priority; });
|
||||
std::int64_t now = now_ms_mono();
|
||||
layout_.apply(*it, snap, cfg_.width, cfg_.height);
|
||||
committed_signature_ = build_signature(it->name, snap);
|
||||
committed_at_ms_ = now;
|
||||
if (motion_mode_) {
|
||||
manual_override_until_ms_ = now + manual_override_duration_ms_;
|
||||
std::fprintf(stderr, "[cfc/composer] manual override '%s' до +%dms\n",
|
||||
it->name.c_str(), manual_override_duration_ms_);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const LayoutTemplate* Composer::pick_best_fit(int need) const
|
||||
{
|
||||
/* Читаем global registry — hot-reload через cfc_layout_load_file
|
||||
* подхватывается на следующем кадре без relink composer'а. */
|
||||
const auto& reg = current_templates();
|
||||
const LayoutTemplate* best = nullptr;
|
||||
int best_waste = -1;
|
||||
int best_prio = -1;
|
||||
for (auto& t : reg) {
|
||||
int n = t.nb_camera_cells();
|
||||
if (n < need) continue;
|
||||
int waste = n - need;
|
||||
if (!best || waste < best_waste ||
|
||||
(waste == best_waste && t.priority > best_prio)) {
|
||||
best = &t;
|
||||
best_waste = waste;
|
||||
best_prio = t.priority;
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
|
||||
/* Overflow → largest. */
|
||||
for (auto& t : reg) {
|
||||
if (!best || t.nb_camera_cells() > best->nb_camera_cells()) best = &t;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
std::vector<PoolEntry*> Composer::collect_active() const
|
||||
{
|
||||
std::vector<PoolEntry*> active;
|
||||
std::int64_t now = now_ms_mono();
|
||||
const_cast<SourcePool&>(pool_).for_each([&](PoolEntry& e) {
|
||||
if (!e.drawable()) return;
|
||||
std::int64_t last = e.last_motion_ms.load();
|
||||
if (last == 0) return;
|
||||
if (now - last > cfg_.motion_ttl_ms) return;
|
||||
active.push_back(&e);
|
||||
});
|
||||
/* Idle fallback: top-priority drawable как single. */
|
||||
if (active.empty()) {
|
||||
PoolEntry* best = nullptr;
|
||||
const_cast<SourcePool&>(pool_).for_each([&](PoolEntry& e) {
|
||||
if (!e.drawable()) return;
|
||||
if (!best || e.priority > best->priority) best = &e;
|
||||
});
|
||||
if (best) active.push_back(best);
|
||||
}
|
||||
std::sort(active.begin(), active.end(),
|
||||
[](PoolEntry* a, PoolEntry* b) { return a->priority > b->priority; });
|
||||
return active;
|
||||
}
|
||||
|
||||
std::string Composer::build_signature(const std::string& tpl_name,
|
||||
const std::vector<PoolEntry*>& active)
|
||||
{
|
||||
std::string sig = tpl_name + "|";
|
||||
std::vector<std::string> keys;
|
||||
keys.reserve(active.size());
|
||||
for (auto* e : active) keys.push_back(e->cuframes_key);
|
||||
std::sort(keys.begin(), keys.end());
|
||||
for (auto& k : keys) { sig += k; sig += ","; }
|
||||
return sig;
|
||||
}
|
||||
|
||||
void Composer::maybe_relayout()
|
||||
{
|
||||
if (!motion_mode_) return;
|
||||
if (current_templates().empty()) return;
|
||||
|
||||
/* Manual override freeze. */
|
||||
std::int64_t now = now_ms_mono();
|
||||
if (manual_override_until_ms_ > now) return;
|
||||
if (manual_override_until_ms_ != 0) {
|
||||
std::fprintf(stderr, "[cfc/composer] manual override expired, возврат в motion-mode\n");
|
||||
manual_override_until_ms_ = 0;
|
||||
committed_signature_.clear(); /* форс relayout */
|
||||
}
|
||||
|
||||
auto active = collect_active();
|
||||
const LayoutTemplate* tpl = pick_best_fit(static_cast<int>(active.size()));
|
||||
if (!tpl) return;
|
||||
|
||||
/* Cap по template'у */
|
||||
int cap = tpl->nb_camera_cells();
|
||||
if (static_cast<int>(active.size()) > cap) active.resize(cap);
|
||||
|
||||
/* Если template имеет больше camera-cells чем активных по motion —
|
||||
* заполнить оставшиеся drawable камерами из pool (по priority),
|
||||
* которые ещё не вошли в active. Это убирает "чёрные ячейки"
|
||||
* в asymmetric layouts (tpl_3/5/6/7 + tpl_4 при active<4). */
|
||||
if (static_cast<int>(active.size()) < cap) {
|
||||
std::vector<PoolEntry*> already(active.begin(), active.end());
|
||||
std::vector<PoolEntry*> extras;
|
||||
const_cast<SourcePool&>(pool_).for_each([&](PoolEntry& e) {
|
||||
if (!e.drawable()) return;
|
||||
for (auto* a : already) if (a == &e) return;
|
||||
extras.push_back(&e);
|
||||
});
|
||||
std::sort(extras.begin(), extras.end(),
|
||||
[](PoolEntry* a, PoolEntry* b) { return a->priority > b->priority; });
|
||||
for (auto* e : extras) {
|
||||
if (static_cast<int>(active.size()) >= cap) break;
|
||||
active.push_back(e);
|
||||
}
|
||||
}
|
||||
|
||||
std::string sig = build_signature(tpl->name, active);
|
||||
|
||||
if (sig == committed_signature_) {
|
||||
pending_signature_.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
/* Рост: active_keys ⊇ committed_keys → switch сразу.
|
||||
* Сравнение через signature просто — committed set, new set. */
|
||||
bool is_grow = false;
|
||||
{
|
||||
/* parse committed_keys */
|
||||
auto pos = committed_signature_.find('|');
|
||||
std::string committed_keys = pos == std::string::npos
|
||||
? std::string() : committed_signature_.substr(pos + 1);
|
||||
std::vector<std::string> ckeys;
|
||||
std::string cur;
|
||||
for (char c : committed_keys) {
|
||||
if (c == ',') { if (!cur.empty()) ckeys.push_back(cur); cur.clear(); }
|
||||
else cur.push_back(c);
|
||||
}
|
||||
std::vector<std::string> nkeys;
|
||||
for (auto* e : active) nkeys.push_back(e->cuframes_key);
|
||||
std::sort(nkeys.begin(), nkeys.end());
|
||||
std::sort(ckeys.begin(), ckeys.end());
|
||||
/* nkeys ⊇ ckeys */
|
||||
is_grow = std::includes(nkeys.begin(), nkeys.end(),
|
||||
ckeys.begin(), ckeys.end());
|
||||
if (is_grow && nkeys.size() == ckeys.size()) is_grow = false; /* идентичны */
|
||||
}
|
||||
|
||||
if (is_grow) {
|
||||
layout_.apply(*tpl, active, cfg_.width, cfg_.height);
|
||||
committed_signature_ = sig;
|
||||
committed_at_ms_ = now;
|
||||
pending_signature_.clear();
|
||||
std::fprintf(stderr, "[cfc/composer] grow → template='%s' active=%zu\n",
|
||||
tpl->name.c_str(), active.size());
|
||||
return;
|
||||
}
|
||||
|
||||
/* Сжатие — ждём shrink_hysteresis. */
|
||||
if (sig != pending_signature_) {
|
||||
pending_signature_ = sig;
|
||||
pending_first_seen_ms_ = now;
|
||||
return;
|
||||
}
|
||||
if (now - pending_first_seen_ms_ < cfg_.shrink_hysteresis_ms) return;
|
||||
|
||||
/* Commit shrink */
|
||||
layout_.apply(*tpl, active, cfg_.width, cfg_.height);
|
||||
committed_signature_ = sig;
|
||||
committed_at_ms_ = now;
|
||||
pending_signature_.clear();
|
||||
std::fprintf(stderr, "[cfc/composer] shrink → template='%s' active=%zu\n",
|
||||
tpl->name.c_str(), active.size());
|
||||
}
|
||||
|
||||
NV12Ref Composer::compose_frame()
|
||||
{
|
||||
/* Если manual cells заданы (через C API без motion-mode) — apply один раз. */
|
||||
if (!motion_mode_ && !manual_cells_.empty() && !manual_applied_) {
|
||||
/* Build LayoutTemplate из manual cells как inline. */
|
||||
LayoutTemplate t;
|
||||
t.name = "manual";
|
||||
for (std::size_t i = 0; i < manual_cells_.size(); i++) {
|
||||
CellTemplate c;
|
||||
/* manual_cells_ хранит pixel Rect — для LayoutTemplate переводим
|
||||
* обратно в микроячейки. Округление в большую сторону безопасно. */
|
||||
const Rect& r = manual_cells_[i].second;
|
||||
c.col = r.x * kGridCols / cfg_.width;
|
||||
c.row = r.y * kGridRows / cfg_.height;
|
||||
c.cs = (r.w * kGridCols + cfg_.width - 1) / cfg_.width;
|
||||
c.rs = (r.h * kGridRows + cfg_.height - 1) / cfg_.height;
|
||||
c.role = CellRole::Camera;
|
||||
c.order = static_cast<int>(i);
|
||||
t.cells.push_back(std::move(c));
|
||||
}
|
||||
/* Build active list из pool entries по cuframes_key. */
|
||||
std::vector<PoolEntry*> snap;
|
||||
for (auto& kv : manual_cells_) {
|
||||
PoolEntry* e = pool_.by_key(kv.first);
|
||||
snap.push_back(e); /* nullptr → BlankCell */
|
||||
}
|
||||
layout_.apply(t, snap, cfg_.width, cfg_.height);
|
||||
committed_signature_ = build_signature(t.name, snap);
|
||||
committed_at_ms_ = now_ms_mono();
|
||||
manual_applied_ = true;
|
||||
}
|
||||
|
||||
maybe_relayout();
|
||||
|
||||
/* clear */
|
||||
CUdeviceptr y = output_.ptr();
|
||||
CUdeviceptr uv = y + static_cast<std::size_t>(pitch_y_) * cfg_.height;
|
||||
cudaMemsetAsync(reinterpret_cast<void*>(y), cfg_.bg_y,
|
||||
static_cast<std::size_t>(pitch_y_) * cfg_.height, stream_);
|
||||
cfc_cugrid_fill_nv12(reinterpret_cast<CUstream>(stream_),
|
||||
y, pitch_y_, uv, pitch_uv_,
|
||||
0, 0, cfg_.width, cfg_.height,
|
||||
cfg_.bg_y, cfg_.bg_u, cfg_.bg_v, 255);
|
||||
|
||||
NV12Ref dst;
|
||||
dst.y_ptr = y;
|
||||
dst.uv_ptr = uv;
|
||||
dst.pitch_y = pitch_y_;
|
||||
dst.pitch_uv = pitch_uv_;
|
||||
dst.frame_w = cfg_.width;
|
||||
dst.frame_h = cfg_.height;
|
||||
|
||||
layout_.render(reinterpret_cast<CUstream>(stream_), dst);
|
||||
|
||||
/* Backward-compat overlays (CLI text/icon, detbox) — поверх Layout. */
|
||||
for (auto* ov : overlays_) {
|
||||
if (!ov) continue;
|
||||
|
||||
/* Detection box рисуется в координатах cell камеры. Cell может
|
||||
* перемещаться по экрану при смене layout — синхронизируем cell-geom
|
||||
* перед каждым draw. */
|
||||
if (cfc_overlay_get_type(ov) == CFC_OVERLAY_DETECTION_BOXES) {
|
||||
const char* fcam = cfc_overlay_detbox_camera_key(ov);
|
||||
if (fcam) {
|
||||
PoolEntry* e = pool_.by_frigate_camera(fcam);
|
||||
if (e) {
|
||||
const Rect* r = layout_.find_camera_cell_rect(e->cuframes_key);
|
||||
if (r) {
|
||||
cfc_overlay_detbox_set_cell_geom(ov, r->x, r->y, r->w, r->h);
|
||||
} else {
|
||||
/* Камеры нет в текущем layout — скрываем рамки. */
|
||||
cfc_overlay_detbox_set_cell_geom(ov, 0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfc_overlay_draw(ov, reinterpret_cast<CUstream>(stream_),
|
||||
y, pitch_y_, uv, pitch_uv_,
|
||||
cfg_.width, cfg_.height);
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
@@ -0,0 +1,197 @@
|
||||
/* composer_c_api — extern "C" ABI shim для C++ Composer (Phase 11b).
|
||||
*
|
||||
* Существующие callers (control.c, frigate_mqtt.c, examples/grid_record.c)
|
||||
* продолжают использовать prototype cfc_composer_* без изменений. Здесь
|
||||
* каждый из них транслируется в вызов соответствующего метода cfc::Composer.
|
||||
*
|
||||
* Opaque handle cfc_composer_t = cfc::Composer (через reinterpret_cast).
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#include "../../include/cuframes_composer/composer.h"
|
||||
#include "../../include/cuframes_composer/cpp/composer.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
inline cfc::Composer* as_cpp(cfc_composer_t* h)
|
||||
{
|
||||
return reinterpret_cast<cfc::Composer*>(h);
|
||||
}
|
||||
inline cfc_composer_t* as_c(cfc::Composer* c)
|
||||
{
|
||||
return reinterpret_cast<cfc_composer_t*>(c);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
int cfc_composer_create(const cfc_composer_config_t* cfg, cfc_composer_t** out)
|
||||
{
|
||||
if (!cfg || !out) return -1;
|
||||
if (cfg->width <= 0 || cfg->height <= 0) return -1;
|
||||
|
||||
cfc::ComposerConfig cpp_cfg;
|
||||
cpp_cfg.width = cfg->width;
|
||||
cpp_cfg.height = cfg->height;
|
||||
cpp_cfg.cuda_device = cfg->cuda_device;
|
||||
if (cfg->bg_y) cpp_cfg.bg_y = cfg->bg_y;
|
||||
if (cfg->bg_u) cpp_cfg.bg_u = cfg->bg_u;
|
||||
if (cfg->bg_v) cpp_cfg.bg_v = cfg->bg_v;
|
||||
|
||||
auto* comp = new (std::nothrow) cfc::Composer(cpp_cfg);
|
||||
if (!comp || !comp->ok()) {
|
||||
delete comp;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Если caller передал cells через --cell → запоминаем как manual cells.
|
||||
* Apply отложен до compose_frame (тогда pool уже наполнен через
|
||||
* add_pool_source). */
|
||||
if (cfg->cells && cfg->num_cells > 0) {
|
||||
std::vector<std::pair<std::string, cfc::Rect>> manual;
|
||||
for (int i = 0; i < cfg->num_cells; i++) {
|
||||
const auto& c = cfg->cells[i];
|
||||
if (!c.source_key) continue;
|
||||
cfc::Rect r;
|
||||
r.x = c.x; r.y = c.y; r.w = c.w; r.h = c.h;
|
||||
manual.emplace_back(std::string(c.source_key), r);
|
||||
}
|
||||
comp->set_manual_cells(manual);
|
||||
/* Также добавляем источник в pool автоматически — иначе lookup
|
||||
* не найдёт его. Priority=0, frigate=none, zones=[]. */
|
||||
cfc::SourcePool::SubscribeOpts opts;
|
||||
if (cfg->consumer_prefix && *cfg->consumer_prefix)
|
||||
opts.consumer_prefix = cfg->consumer_prefix;
|
||||
if (cfg->reconnect_min_ms) opts.reconnect_min_ms = cfg->reconnect_min_ms;
|
||||
if (cfg->reconnect_max_ms) opts.reconnect_max_ms = cfg->reconnect_max_ms;
|
||||
if (cfg->stale_threshold_ms) opts.stale_threshold_ms = cfg->stale_threshold_ms;
|
||||
if (cfg->dead_threshold_ms) opts.dead_threshold_ms = cfg->dead_threshold_ms;
|
||||
for (const auto& kv : manual) {
|
||||
comp->pool().add(kv.first, "", 0, {}, opts);
|
||||
}
|
||||
}
|
||||
|
||||
std::fprintf(stderr, "[cfc/composer] C++ ABI shim, %dx%d, %d manual cells\n",
|
||||
cfg->width, cfg->height, cfg->num_cells);
|
||||
*out = as_c(comp);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_compose(cfc_composer_t* h,
|
||||
CUdeviceptr* out_y_ptr,
|
||||
int* out_pitch_y,
|
||||
int* out_width,
|
||||
int* out_height)
|
||||
{
|
||||
if (!h) return -1;
|
||||
cfc::NV12Ref ref = as_cpp(h)->compose_frame();
|
||||
if (out_y_ptr) *out_y_ptr = ref.y_ptr;
|
||||
if (out_pitch_y) *out_pitch_y = ref.pitch_y;
|
||||
if (out_width) *out_width = ref.frame_w;
|
||||
if (out_height) *out_height = ref.frame_h;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_add_overlay(cfc_composer_t* h, cfc_overlay_t* ov)
|
||||
{
|
||||
if (!h) return -1;
|
||||
return as_cpp(h)->add_overlay(ov);
|
||||
}
|
||||
|
||||
cfc_overlay_t* cfc_composer_find_overlay(cfc_composer_t* h, const char* id)
|
||||
{
|
||||
if (!h || !id) return nullptr;
|
||||
return as_cpp(h)->find_overlay(id);
|
||||
}
|
||||
|
||||
int cfc_composer_set_layout(cfc_composer_t* h, const char* layout_name)
|
||||
{
|
||||
if (!h || !layout_name) return -1;
|
||||
return as_cpp(h)->set_layout(layout_name) ? 0 : -1;
|
||||
}
|
||||
|
||||
const char* cfc_composer_current_layout(cfc_composer_t* h)
|
||||
{
|
||||
if (!h) return nullptr;
|
||||
const std::string& n = as_cpp(h)->current_layout_name();
|
||||
return n.empty() ? nullptr : n.c_str();
|
||||
}
|
||||
|
||||
int cfc_composer_add_pool_source(cfc_composer_t* h,
|
||||
const char* cuframes_key,
|
||||
const char* frigate_camera,
|
||||
int priority,
|
||||
const char* required_zones)
|
||||
{
|
||||
if (!h || !cuframes_key) return -1;
|
||||
std::vector<std::string> zones;
|
||||
if (required_zones && *required_zones) {
|
||||
std::string cur;
|
||||
for (const char* p = required_zones; *p; p++) {
|
||||
if (*p == ':') { if (!cur.empty()) zones.push_back(cur); cur.clear(); }
|
||||
else cur.push_back(*p);
|
||||
}
|
||||
if (!cur.empty()) zones.push_back(cur);
|
||||
}
|
||||
cfc::SourcePool::SubscribeOpts opts;
|
||||
int idx = as_cpp(h)->pool().add(cuframes_key,
|
||||
frigate_camera ? frigate_camera : "",
|
||||
priority, zones, opts);
|
||||
std::fprintf(stderr, "[cfc/composer] pool+ '%s' (frigate=%s prio=%d zones=%zu) → idx=%d\n",
|
||||
cuframes_key, frigate_camera ? frigate_camera : "-",
|
||||
priority, zones.size(), idx);
|
||||
return idx >= 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int cfc_composer_set_motion_mode(cfc_composer_t* h, int on, int ttl_ms)
|
||||
{
|
||||
if (!h) return -1;
|
||||
as_cpp(h)->set_motion_mode(on != 0, ttl_ms);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_get_motion_mode(cfc_composer_t* h)
|
||||
{
|
||||
return h ? (as_cpp(h)->motion_mode() ? 1 : 0) : 0;
|
||||
}
|
||||
|
||||
int cfc_composer_motion_pulse(cfc_composer_t* h,
|
||||
const char* frigate_camera,
|
||||
const char* const* current_zones,
|
||||
int n_zones)
|
||||
{
|
||||
if (!h || !frigate_camera) return -1;
|
||||
std::vector<std::string> zones;
|
||||
for (int i = 0; i < n_zones; i++) {
|
||||
if (current_zones[i]) zones.emplace_back(current_zones[i]);
|
||||
}
|
||||
as_cpp(h)->pool().motion_pulse(frigate_camera, zones);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_get_health(cfc_composer_t* h, cfc_composer_health_t* out)
|
||||
{
|
||||
if (!h || !out) return -1;
|
||||
auto hh = as_cpp(h)->get_health();
|
||||
out->total = hh.total;
|
||||
out->active = hh.active;
|
||||
out->stale = hh.stale;
|
||||
out->dead = hh.dead;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_composer_destroy(cfc_composer_t* h)
|
||||
{
|
||||
if (h) delete as_cpp(h);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,168 @@
|
||||
/* LabelDecoration — реализация (Phase 11b).
|
||||
*
|
||||
* UTF-8 → FreeType glyph rendering → RGBA atlas (CPU) → cuMemcpy → CUdeviceptr.
|
||||
* На draw: cfc_cugrid_blit_rgba_nv12 (existing kernel, zero-copy на GPU side).
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#include "../../include/cuframes_composer/cpp/label_decoration.hpp"
|
||||
#include "../../include/cuframes_composer/cugrid.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
/* UTF-8 декодер — возвращает true если ещё есть данные, advance'ит p. */
|
||||
static bool utf8_next(const char*& p, std::uint32_t& cp)
|
||||
{
|
||||
auto s = reinterpret_cast<const unsigned char*>(p);
|
||||
if (!*s) return false;
|
||||
unsigned char c = *s;
|
||||
if (c < 0x80) { cp = c; p += 1; return true; }
|
||||
if ((c & 0xE0) == 0xC0 && s[1]) { cp = ((c & 0x1F) << 6) | (s[1] & 0x3F); p += 2; return true; }
|
||||
if ((c & 0xF0) == 0xE0 && s[1] && s[2]) { cp = ((c & 0x0F) << 12) | ((s[1] & 0x3F) << 6) | (s[2] & 0x3F); p += 3; return true; }
|
||||
if ((c & 0xF8) == 0xF0 && s[1] && s[2] && s[3]) {
|
||||
cp = ((c & 0x07) << 18) | ((s[1] & 0x3F) << 12) | ((s[2] & 0x3F) << 6) | (s[3] & 0x3F);
|
||||
p += 4; return true;
|
||||
}
|
||||
cp = 0xFFFD; p += 1; return true;
|
||||
}
|
||||
|
||||
LabelDecoration::LabelDecoration(const std::string& text, const LabelStyle& style)
|
||||
: text_(text), style_(style)
|
||||
{
|
||||
if (FT_Init_FreeType(&ft_lib_) != 0) {
|
||||
std::fprintf(stderr, "[cfc/label] FT_Init_FreeType failed\n");
|
||||
return;
|
||||
}
|
||||
if (FT_New_Face(ft_lib_, style_.font_path.c_str(), 0, &face_) != 0) {
|
||||
std::fprintf(stderr, "[cfc/label] FT_New_Face('%s') failed\n",
|
||||
style_.font_path.c_str());
|
||||
FT_Done_FreeType(ft_lib_);
|
||||
ft_lib_ = nullptr;
|
||||
return;
|
||||
}
|
||||
if (FT_Set_Pixel_Sizes(face_, 0, style_.pixel_size) != 0) {
|
||||
std::fprintf(stderr, "[cfc/label] FT_Set_Pixel_Sizes(%d) failed\n",
|
||||
style_.pixel_size);
|
||||
}
|
||||
rebuild_atlas();
|
||||
}
|
||||
|
||||
LabelDecoration::~LabelDecoration()
|
||||
{
|
||||
if (atlas_) cuMemFree(atlas_);
|
||||
if (face_) FT_Done_Face(face_);
|
||||
if (ft_lib_) FT_Done_FreeType(ft_lib_);
|
||||
}
|
||||
|
||||
bool LabelDecoration::measure(int& w, int& h, int& ascent) const
|
||||
{
|
||||
int width = 0;
|
||||
int asc = face_->size->metrics.ascender >> 6;
|
||||
int desc = -(face_->size->metrics.descender >> 6);
|
||||
if (asc <= 0) asc = face_->size->metrics.height >> 6;
|
||||
if (desc < 0) desc = 0;
|
||||
|
||||
const char* p = text_.c_str();
|
||||
std::uint32_t cp;
|
||||
while (utf8_next(p, cp)) {
|
||||
if (FT_Load_Char(face_, cp, FT_LOAD_DEFAULT) != 0) continue;
|
||||
width += face_->glyph->advance.x >> 6;
|
||||
}
|
||||
if (width <= 0) width = 1;
|
||||
w = width;
|
||||
h = asc + desc;
|
||||
ascent = asc;
|
||||
return true;
|
||||
}
|
||||
|
||||
void LabelDecoration::render_to_cpu(unsigned char* rgba, int w, int h, int ascent) const
|
||||
{
|
||||
std::memset(rgba, 0, static_cast<std::size_t>(w) * h * 4);
|
||||
|
||||
int pen_x = 0;
|
||||
const char* p = text_.c_str();
|
||||
std::uint32_t cp;
|
||||
while (utf8_next(p, cp)) {
|
||||
if (FT_Load_Char(face_, cp, FT_LOAD_RENDER) != 0) continue;
|
||||
FT_Bitmap* bm = &face_->glyph->bitmap;
|
||||
int bx = face_->glyph->bitmap_left;
|
||||
int by = ascent - face_->glyph->bitmap_top;
|
||||
for (unsigned gy = 0; gy < bm->rows; gy++) {
|
||||
int dy = by + static_cast<int>(gy);
|
||||
if (dy < 0 || dy >= h) continue;
|
||||
for (unsigned gx = 0; gx < bm->width; gx++) {
|
||||
int dx = pen_x + bx + static_cast<int>(gx);
|
||||
if (dx < 0 || dx >= w) continue;
|
||||
unsigned char a = bm->buffer[gy * bm->pitch + gx];
|
||||
if (!a) continue;
|
||||
unsigned char* dst = rgba + (static_cast<std::size_t>(dy) * w + dx) * 4;
|
||||
int ca = dst[3];
|
||||
int new_a = a + (ca * (255 - a)) / 255;
|
||||
if (new_a > 0) {
|
||||
dst[0] = static_cast<unsigned char>((style_.r * a + dst[0] * ca * (255 - a) / 255) / new_a);
|
||||
dst[1] = static_cast<unsigned char>((style_.g * a + dst[1] * ca * (255 - a) / 255) / new_a);
|
||||
dst[2] = static_cast<unsigned char>((style_.b * a + dst[2] * ca * (255 - a) / 255) / new_a);
|
||||
dst[3] = static_cast<unsigned char>(new_a);
|
||||
}
|
||||
}
|
||||
}
|
||||
pen_x += face_->glyph->advance.x >> 6;
|
||||
}
|
||||
}
|
||||
|
||||
bool LabelDecoration::rebuild_atlas()
|
||||
{
|
||||
if (!face_) return false;
|
||||
int w = 0, h = 0, ascent = 0;
|
||||
if (!measure(w, h, ascent)) return false;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
|
||||
auto cpu = static_cast<unsigned char*>(std::malloc(static_cast<std::size_t>(w) * h * 4));
|
||||
if (!cpu) return false;
|
||||
render_to_cpu(cpu, w, h, ascent);
|
||||
|
||||
if (atlas_) { cuMemFree(atlas_); atlas_ = 0; }
|
||||
CUresult cr = cuMemAlloc(&atlas_, static_cast<std::size_t>(w) * h * 4);
|
||||
if (cr != CUDA_SUCCESS) { std::free(cpu); return false; }
|
||||
cr = cuMemcpyHtoD(atlas_, cpu, static_cast<std::size_t>(w) * h * 4);
|
||||
std::free(cpu);
|
||||
if (cr != CUDA_SUCCESS) {
|
||||
cuMemFree(atlas_); atlas_ = 0; return false;
|
||||
}
|
||||
atlas_w_ = w;
|
||||
atlas_h_ = h;
|
||||
atlas_pitch_ = w * 4;
|
||||
return true;
|
||||
}
|
||||
|
||||
void LabelDecoration::set_text(const std::string& text)
|
||||
{
|
||||
if (text == text_) return;
|
||||
text_ = text;
|
||||
rebuild_atlas();
|
||||
}
|
||||
|
||||
void LabelDecoration::draw(CUstream stream, NV12Ref& dst, const Rect& parent_rect)
|
||||
{
|
||||
if (!style_.visible || !atlas_ || style_.alpha <= 0) return;
|
||||
int x = parent_rect.x + style_.pad;
|
||||
int y = parent_rect.y + style_.pad;
|
||||
x &= ~1; y &= ~1;
|
||||
if (x >= dst.frame_w || y >= dst.frame_h) return;
|
||||
|
||||
cfc_cugrid_blit_rgba_nv12(
|
||||
stream,
|
||||
dst.y_ptr, dst.pitch_y,
|
||||
dst.uv_ptr, dst.pitch_uv,
|
||||
x, y,
|
||||
atlas_, atlas_w_, atlas_h_, atlas_pitch_,
|
||||
style_.alpha);
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Layout — реализация (Phase 11b). */
|
||||
|
||||
#include "../../include/cuframes_composer/cpp/layout.hpp"
|
||||
#include "../../include/cuframes_composer/cpp/blank_cell.hpp"
|
||||
#include "../../include/cuframes_composer/cpp/border_decoration.hpp"
|
||||
#include "../../include/cuframes_composer/cpp/camera_cell.hpp"
|
||||
#include "../../include/cuframes_composer/cpp/label_decoration.hpp"
|
||||
#include "../../include/cuframes_composer/cpp/widget_cell.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
void Layout::apply(const LayoutTemplate& tpl,
|
||||
const std::vector<PoolEntry*>& active_sorted,
|
||||
int frame_w, int frame_h)
|
||||
{
|
||||
cells_.clear();
|
||||
current_name_ = tpl.name;
|
||||
|
||||
/* Подготовим cells в порядке camera-template'ов (отсортированных
|
||||
* по order ASC), чтобы active[0] попадал в order=0 (главная), [1]→order=1. */
|
||||
std::vector<const CellTemplate*> camera_templates;
|
||||
std::vector<const CellTemplate*> widget_templates;
|
||||
camera_templates.reserve(tpl.cells.size());
|
||||
for (auto& c : tpl.cells) {
|
||||
if (c.role == CellRole::Camera) camera_templates.push_back(&c);
|
||||
else widget_templates.push_back(&c);
|
||||
}
|
||||
std::sort(camera_templates.begin(), camera_templates.end(),
|
||||
[](const CellTemplate* a, const CellTemplate* b) {
|
||||
return a->order < b->order;
|
||||
});
|
||||
|
||||
/* Серая рамка по умолчанию — отделяет ячейки друг от друга. */
|
||||
BorderStyle border_style;
|
||||
border_style.thickness = 2;
|
||||
border_style.color_y = 180;
|
||||
border_style.color_u = 128;
|
||||
border_style.color_v = 128;
|
||||
border_style.alpha = 220;
|
||||
|
||||
/* CameraCells */
|
||||
for (std::size_t i = 0; i < camera_templates.size(); ++i) {
|
||||
Rect r = to_pixels(*camera_templates[i], frame_w, frame_h);
|
||||
if (i < active_sorted.size() && active_sorted[i] && active_sorted[i]->source) {
|
||||
auto cell = std::make_unique<CameraCell>(r, active_sorted[i]->source,
|
||||
active_sorted[i]->cuframes_key);
|
||||
cell->add_decoration(std::make_unique<BorderDecoration>(border_style));
|
||||
/* Label с именем камеры и приоритетом. */
|
||||
char label_buf[96];
|
||||
std::snprintf(label_buf, sizeof(label_buf), "%s prio=%d",
|
||||
active_sorted[i]->cuframes_key.c_str(),
|
||||
active_sorted[i]->priority);
|
||||
cell->add_decoration(std::make_unique<LabelDecoration>(label_buf, LabelStyle{}));
|
||||
cells_.push_back(std::move(cell));
|
||||
} else {
|
||||
/* Нет active под этот слот → blank. */
|
||||
auto cell = std::make_unique<BlankCell>(r);
|
||||
cell->add_decoration(std::make_unique<BorderDecoration>(border_style));
|
||||
cells_.push_back(std::move(cell));
|
||||
}
|
||||
}
|
||||
|
||||
/* WidgetCells */
|
||||
for (auto* wt : widget_templates) {
|
||||
Rect r = to_pixels(*wt, frame_w, frame_h);
|
||||
auto cell = std::make_unique<WidgetCell>(r, wt->widget);
|
||||
cell->add_decoration(std::make_unique<BorderDecoration>(border_style));
|
||||
if (!wt->widget.empty()) {
|
||||
cell->add_decoration(std::make_unique<LabelDecoration>(wt->widget, LabelStyle{}));
|
||||
}
|
||||
cells_.push_back(std::move(cell));
|
||||
}
|
||||
}
|
||||
|
||||
void Layout::render(CUstream stream, NV12Ref& dst)
|
||||
{
|
||||
for (auto& c : cells_) c->draw(stream, dst);
|
||||
}
|
||||
|
||||
const Rect* Layout::find_camera_cell_rect(const std::string& source_key) const
|
||||
{
|
||||
for (auto& c : cells_) {
|
||||
auto* cc = dynamic_cast<const CameraCell*>(c.get());
|
||||
if (cc && cc->source_key() == source_key) {
|
||||
return &cc->geometry();
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
@@ -0,0 +1,136 @@
|
||||
/* layouts_c_api — extern "C" ABI shim над template_loader (Phase 11b).
|
||||
*
|
||||
* Сохраняет совместимость с control.c::cmd_list_layouts/_get_layout/_set_layout
|
||||
* через старый интерфейс cfc_layout_find / cfc_layout_all.
|
||||
*
|
||||
* Стратегия: static cache из cfc_layout_t structs, заполняется при
|
||||
* load_file/reload. cfc_layout_find/_all возвращают указатели в этот cache.
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#include "../../include/cuframes_composer/layouts.h"
|
||||
#include "../../include/cuframes_composer/cpp/template.hpp"
|
||||
#include "../../include/cuframes_composer/cpp/template_loader.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
static std::mutex g_mu;
|
||||
static std::vector<cfc_layout_t> g_c_cache;
|
||||
static std::string g_loaded_path;
|
||||
static std::vector<cfc::LayoutTemplate> g_cpp_cache;
|
||||
|
||||
void rebuild_c_cache_locked()
|
||||
{
|
||||
g_c_cache.clear();
|
||||
g_c_cache.reserve(g_cpp_cache.size());
|
||||
for (const auto& t : g_cpp_cache) {
|
||||
cfc_layout_t l{};
|
||||
std::strncpy(l.name, t.name.c_str(), sizeof(l.name) - 1);
|
||||
l.priority = t.priority;
|
||||
l.nb_cells = static_cast<int>(t.cells.size());
|
||||
if (l.nb_cells > CFC_LAYOUT_MAX_CELLS) l.nb_cells = CFC_LAYOUT_MAX_CELLS;
|
||||
l.nb_camera_cells = 0;
|
||||
for (int i = 0; i < l.nb_cells; i++) {
|
||||
const auto& c = t.cells[i];
|
||||
l.cells[i].col = c.col;
|
||||
l.cells[i].row = c.row;
|
||||
l.cells[i].cs = c.cs;
|
||||
l.cells[i].rs = c.rs;
|
||||
l.cells[i].role = (c.role == cfc::CellRole::Widget)
|
||||
? CFC_CELL_WIDGET : CFC_CELL_CAMERA;
|
||||
l.cells[i].order = c.order;
|
||||
std::strncpy(l.cells[i].widget, c.widget.c_str(),
|
||||
sizeof(l.cells[i].widget) - 1);
|
||||
if (l.cells[i].role == CFC_CELL_CAMERA) l.nb_camera_cells++;
|
||||
}
|
||||
g_c_cache.push_back(l);
|
||||
}
|
||||
}
|
||||
|
||||
void ensure_loaded_locked()
|
||||
{
|
||||
/* Источник истины = global registry; кеш C-структур пересинхронизируется
|
||||
* каждый раз когда состав изменился (поэтому простая проверка empty
|
||||
* не годится — может появиться обновление через load_file). */
|
||||
g_cpp_cache = cfc::current_templates();
|
||||
rebuild_c_cache_locked();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
const cfc_layout_t* cfc_layout_find(const char* name)
|
||||
{
|
||||
if (!name) return nullptr;
|
||||
std::lock_guard<std::mutex> lk(g_mu);
|
||||
ensure_loaded_locked();
|
||||
for (const auto& l : g_c_cache) {
|
||||
if (std::strcmp(l.name, name) == 0) return &l;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const cfc_layout_t* cfc_layout_all(int* out_count)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_mu);
|
||||
ensure_loaded_locked();
|
||||
if (out_count) *out_count = static_cast<int>(g_c_cache.size());
|
||||
return g_c_cache.data();
|
||||
}
|
||||
|
||||
void cfc_layout_to_pixels(const cfc_cell_t* cell, int W, int H,
|
||||
int* out_x, int* out_y, int* out_w, int* out_h)
|
||||
{
|
||||
if (!cell) return;
|
||||
int x = (cell->col * W) / CFC_GRID_COLS;
|
||||
int y = (cell->row * H) / CFC_GRID_ROWS;
|
||||
int w = (cell->cs * W) / CFC_GRID_COLS;
|
||||
int h = (cell->rs * H) / CFC_GRID_ROWS;
|
||||
x &= ~1; y &= ~1; w &= ~1; h &= ~1;
|
||||
if (x + w > W) w = W - x;
|
||||
if (y + h > H) h = H - y;
|
||||
if (out_x) *out_x = x;
|
||||
if (out_y) *out_y = y;
|
||||
if (out_w) *out_w = w;
|
||||
if (out_h) *out_h = h;
|
||||
}
|
||||
|
||||
int cfc_layout_load_file(const char* path)
|
||||
{
|
||||
if (!path) return -3;
|
||||
int r = cfc::load_into_current(path); /* обновит global registry */
|
||||
if (r > 0) {
|
||||
std::lock_guard<std::mutex> lk(g_mu);
|
||||
g_cpp_cache = cfc::current_templates();
|
||||
rebuild_c_cache_locked();
|
||||
g_loaded_path = path;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
int cfc_layout_reload(void)
|
||||
{
|
||||
std::string path;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_mu);
|
||||
path = g_loaded_path;
|
||||
}
|
||||
if (path.empty()) return -1;
|
||||
return cfc_layout_load_file(path.c_str());
|
||||
}
|
||||
|
||||
const char* cfc_layout_loaded_path(void)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_mu);
|
||||
return g_loaded_path.empty() ? nullptr : g_loaded_path.c_str();
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,317 @@
|
||||
/* MqttOverlay — реализация (Phase 11b). */
|
||||
|
||||
#include "../../include/cuframes_composer/cpp/mqtt_overlay.hpp"
|
||||
|
||||
#include <json-c/json.h>
|
||||
#include <mosquitto.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
// ── MqttOverlayItem ──────────────────────────────────────────────────────
|
||||
|
||||
MqttOverlayItem::MqttOverlayItem(const MqttOverlayCfg& cfg,
|
||||
const MqttBrokerCfg& broker,
|
||||
int frame_w, int frame_h)
|
||||
: cfg_(cfg), broker_(broker), frame_w_(frame_w), frame_h_(frame_h)
|
||||
{
|
||||
mosquitto_lib_init();
|
||||
}
|
||||
|
||||
MqttOverlayItem::~MqttOverlayItem()
|
||||
{
|
||||
running_.store(false);
|
||||
if (mosq_) {
|
||||
mosquitto_disconnect(mosq_);
|
||||
mosquitto_loop_stop(mosq_, true);
|
||||
mosquitto_destroy(mosq_);
|
||||
mosq_ = nullptr;
|
||||
}
|
||||
/* Overlay ownership — Composer; не уничтожаем. */
|
||||
}
|
||||
|
||||
void MqttOverlayItem::on_connect(struct mosquitto* m, void* user, int rc)
|
||||
{
|
||||
auto* self = static_cast<MqttOverlayItem*>(user);
|
||||
if (rc == 0) {
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay/%s] connected, subscribe '%s'\n",
|
||||
self->cfg_.id.c_str(), self->cfg_.topic.c_str());
|
||||
mosquitto_subscribe(m, nullptr, self->cfg_.topic.c_str(), 0);
|
||||
} else {
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay/%s] connect failed: %s\n",
|
||||
self->cfg_.id.c_str(), mosquitto_connack_string(rc));
|
||||
}
|
||||
}
|
||||
|
||||
void MqttOverlayItem::on_message(struct mosquitto*, void* user,
|
||||
const struct mosquitto_message* msg)
|
||||
{
|
||||
auto* self = static_cast<MqttOverlayItem*>(user);
|
||||
if (!msg || !msg->payload || msg->payloadlen <= 0) return;
|
||||
self->handle_payload(static_cast<const char*>(msg->payload),
|
||||
static_cast<std::size_t>(msg->payloadlen));
|
||||
}
|
||||
|
||||
void MqttOverlayItem::handle_payload(const char* payload, std::size_t len)
|
||||
{
|
||||
std::string buf(payload, len);
|
||||
std::string text;
|
||||
|
||||
if (!cfg_.json_field.empty()) {
|
||||
struct json_object* root = json_tokener_parse(buf.c_str());
|
||||
if (!root) return;
|
||||
struct json_object* v = nullptr;
|
||||
json_object_object_get_ex(root, cfg_.json_field.c_str(), &v);
|
||||
if (!v) { json_object_put(root); return; }
|
||||
|
||||
char tmp[128];
|
||||
/* Если значение numeric — извлекаем как double, форматируем
|
||||
* printf'ом. Если string — как %s. */
|
||||
if (json_object_is_type(v, json_type_double) ||
|
||||
json_object_is_type(v, json_type_int)) {
|
||||
double d = json_object_get_double(v);
|
||||
std::snprintf(tmp, sizeof(tmp), cfg_.format.c_str(), d);
|
||||
} else {
|
||||
const char* s = json_object_get_string(v);
|
||||
std::snprintf(tmp, sizeof(tmp), cfg_.format.c_str(), s ? s : "");
|
||||
}
|
||||
json_object_put(root);
|
||||
text = tmp;
|
||||
} else {
|
||||
/* Raw payload — format должен быть "%s" или совместимый. */
|
||||
char tmp[256];
|
||||
std::snprintf(tmp, sizeof(tmp), cfg_.format.c_str(), buf.c_str());
|
||||
text = tmp;
|
||||
}
|
||||
|
||||
update_text(text);
|
||||
}
|
||||
|
||||
void MqttOverlayItem::update_text(const std::string& text)
|
||||
{
|
||||
if (text == last_text_) return;
|
||||
last_text_ = text;
|
||||
if (!overlay_) return;
|
||||
|
||||
cfc_overlay_text_config_t tc{};
|
||||
tc.font_path = cfg_.font_path.c_str();
|
||||
tc.text = text.c_str();
|
||||
tc.pixel_size = cfg_.pixel_size;
|
||||
tc.x = 0; tc.y = 0;
|
||||
tc.r = cfg_.r; tc.g = cfg_.g; tc.b = cfg_.b;
|
||||
tc.extra_alpha = cfg_.alpha;
|
||||
tc.visible = 1;
|
||||
tc.bg_alpha = cfg_.bg_alpha;
|
||||
tc.bg_y = cfg_.bg_y; tc.bg_u = cfg_.bg_u; tc.bg_v = cfg_.bg_v;
|
||||
tc.bg_pad = cfg_.bg_pad;
|
||||
cfc_overlay_update_text(overlay_, &tc);
|
||||
reposition_overlay();
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay/%s] '%s'\n",
|
||||
cfg_.id.c_str(), text.c_str());
|
||||
}
|
||||
|
||||
void MqttOverlayItem::reposition_overlay()
|
||||
{
|
||||
if (!overlay_) return;
|
||||
int w = 0, h = 0;
|
||||
cfc_overlay_text_size(overlay_, &w, &h);
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
int x = 0, y = 0;
|
||||
if (cfg_.anchor == "right-bottom") {
|
||||
x = frame_w_ - w - cfg_.margin_x;
|
||||
y = frame_h_ - h - cfg_.margin_y;
|
||||
} else if (cfg_.anchor == "right-top") {
|
||||
x = frame_w_ - w - cfg_.margin_x;
|
||||
y = cfg_.margin_y;
|
||||
} else if (cfg_.anchor == "left-bottom") {
|
||||
x = cfg_.margin_x;
|
||||
y = frame_h_ - h - cfg_.margin_y;
|
||||
} else if (cfg_.anchor == "left-top") {
|
||||
x = cfg_.margin_x;
|
||||
y = cfg_.margin_y;
|
||||
} else if (cfg_.anchor == "center") {
|
||||
x = (frame_w_ - w) / 2;
|
||||
y = (frame_h_ - h) / 2;
|
||||
} else {
|
||||
x = cfg_.margin_x; y = cfg_.margin_y;
|
||||
}
|
||||
x &= ~1; y &= ~1;
|
||||
if (x < 0) x = 0;
|
||||
if (y < 0) y = 0;
|
||||
|
||||
cfc_overlay_text_config_t tc{};
|
||||
tc.font_path = cfg_.font_path.c_str();
|
||||
tc.text = last_text_.c_str();
|
||||
tc.pixel_size = cfg_.pixel_size;
|
||||
tc.x = x; tc.y = y;
|
||||
tc.r = cfg_.r; tc.g = cfg_.g; tc.b = cfg_.b;
|
||||
tc.extra_alpha = cfg_.alpha;
|
||||
tc.visible = 1;
|
||||
tc.bg_alpha = cfg_.bg_alpha;
|
||||
tc.bg_y = cfg_.bg_y; tc.bg_u = cfg_.bg_u; tc.bg_v = cfg_.bg_v;
|
||||
tc.bg_pad = cfg_.bg_pad;
|
||||
cfc_overlay_update_text(overlay_, &tc);
|
||||
}
|
||||
|
||||
bool MqttOverlayItem::start()
|
||||
{
|
||||
/* Persistent text overlay — сразу visible=1 с placeholder, чтобы было
|
||||
* видно (с подложкой) даже без MQTT-сообщения. */
|
||||
const std::string ph = cfg_.placeholder.empty() ? std::string("—") : cfg_.placeholder;
|
||||
cfc_overlay_text_config_t tc{};
|
||||
tc.font_path = cfg_.font_path.c_str();
|
||||
tc.text = ph.c_str();
|
||||
tc.pixel_size = cfg_.pixel_size;
|
||||
tc.x = cfg_.margin_x; tc.y = cfg_.margin_y;
|
||||
tc.r = cfg_.r; tc.g = cfg_.g; tc.b = cfg_.b;
|
||||
tc.extra_alpha = cfg_.alpha;
|
||||
tc.visible = 1;
|
||||
tc.bg_alpha = cfg_.bg_alpha;
|
||||
tc.bg_y = cfg_.bg_y; tc.bg_u = cfg_.bg_u; tc.bg_v = cfg_.bg_v;
|
||||
tc.bg_pad = cfg_.bg_pad;
|
||||
if (cfc_overlay_create_text(&tc, &overlay_) != 0) {
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay/%s] create_text failed (font '%s')\n",
|
||||
cfg_.id.c_str(), cfg_.font_path.c_str());
|
||||
return false;
|
||||
}
|
||||
cfc_overlay_set_id(overlay_, cfg_.id.c_str());
|
||||
last_text_ = ph;
|
||||
reposition_overlay(); /* поставить в anchor сразу */
|
||||
|
||||
/* MQTT subscriber. */
|
||||
char cid[64];
|
||||
std::snprintf(cid, sizeof(cid), "composer-overlay-%s-%p",
|
||||
cfg_.id.c_str(), static_cast<void*>(this));
|
||||
mosq_ = mosquitto_new(cid, true, this);
|
||||
if (!mosq_) return false;
|
||||
if (!broker_.username.empty()) {
|
||||
mosquitto_username_pw_set(mosq_, broker_.username.c_str(),
|
||||
broker_.password.empty() ? nullptr : broker_.password.c_str());
|
||||
}
|
||||
mosquitto_connect_callback_set(mosq_, &MqttOverlayItem::on_connect);
|
||||
mosquitto_message_callback_set(mosq_, &MqttOverlayItem::on_message);
|
||||
mosquitto_reconnect_delay_set(mosq_, 1, 30, true);
|
||||
|
||||
int r = mosquitto_connect_async(mosq_, broker_.host.c_str(), broker_.port, 60);
|
||||
if (r != MOSQ_ERR_SUCCESS) {
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay/%s] connect_async failed: %s\n",
|
||||
cfg_.id.c_str(), mosquitto_strerror(r));
|
||||
return false;
|
||||
}
|
||||
r = mosquitto_loop_start(mosq_);
|
||||
if (r != MOSQ_ERR_SUCCESS) {
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay/%s] loop_start failed: %s\n",
|
||||
cfg_.id.c_str(), mosquitto_strerror(r));
|
||||
return false;
|
||||
}
|
||||
running_.store(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── MqttOverlayManager ───────────────────────────────────────────────────
|
||||
|
||||
namespace {
|
||||
const char* jstr(struct json_object* o, const char* k, const char* def = "")
|
||||
{
|
||||
struct json_object* v;
|
||||
if (!json_object_object_get_ex(o, k, &v)) return def;
|
||||
return json_object_get_string(v);
|
||||
}
|
||||
int jint(struct json_object* o, const char* k, int def)
|
||||
{
|
||||
struct json_object* v;
|
||||
if (!json_object_object_get_ex(o, k, &v)) return def;
|
||||
return json_object_get_int(v);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int MqttOverlayManager::load_from_file(const std::string& path, int W, int H)
|
||||
{
|
||||
std::ifstream f(path);
|
||||
if (!f.is_open()) {
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay] %s: open failed\n", path.c_str());
|
||||
return -3;
|
||||
}
|
||||
std::stringstream ss; ss << f.rdbuf();
|
||||
std::string buf = ss.str();
|
||||
|
||||
struct json_object* root = json_tokener_parse(buf.c_str());
|
||||
if (!root) {
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay] %s: parse failed\n", path.c_str());
|
||||
return -1;
|
||||
}
|
||||
struct json_object* jarr = nullptr;
|
||||
if (!json_object_object_get_ex(root, "overlays", &jarr) ||
|
||||
!json_object_is_type(jarr, json_type_array)) {
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay] %s: 'overlays' missing\n", path.c_str());
|
||||
json_object_put(root);
|
||||
return -2;
|
||||
}
|
||||
|
||||
clear();
|
||||
int n = static_cast<int>(json_object_array_length(jarr));
|
||||
for (int i = 0; i < n; i++) {
|
||||
struct json_object* jo = json_object_array_get_idx(jarr, i);
|
||||
if (!jo) continue;
|
||||
MqttOverlayCfg cfg;
|
||||
cfg.id = jstr(jo, "id", "");
|
||||
cfg.topic = jstr(jo, "topic", "");
|
||||
cfg.json_field = jstr(jo, "json_field", "");
|
||||
cfg.format = jstr(jo, "format", "%s");
|
||||
cfg.anchor = jstr(jo, "anchor", "right-bottom");
|
||||
cfg.margin_x = jint(jo, "margin_x", 32);
|
||||
cfg.margin_y = jint(jo, "margin_y", 24);
|
||||
cfg.pixel_size = jint(jo, "pixel_size", 32);
|
||||
cfg.alpha = jint(jo, "alpha", 230);
|
||||
cfg.bg_alpha = jint(jo, "bg_alpha", 160);
|
||||
cfg.bg_y = jint(jo, "bg_y", 16);
|
||||
cfg.bg_u = jint(jo, "bg_u", 128);
|
||||
cfg.bg_v = jint(jo, "bg_v", 128);
|
||||
cfg.bg_pad = jint(jo, "bg_pad", 10);
|
||||
const char* ph = jstr(jo, "placeholder", "");
|
||||
if (*ph) cfg.placeholder = ph;
|
||||
const char* fp = jstr(jo, "font_path", "");
|
||||
if (*fp) cfg.font_path = fp;
|
||||
|
||||
struct json_object* jcolor = nullptr;
|
||||
if (json_object_object_get_ex(jo, "color", &jcolor) &&
|
||||
json_object_is_type(jcolor, json_type_array) &&
|
||||
json_object_array_length(jcolor) >= 3) {
|
||||
cfg.r = json_object_get_int(json_object_array_get_idx(jcolor, 0));
|
||||
cfg.g = json_object_get_int(json_object_array_get_idx(jcolor, 1));
|
||||
cfg.b = json_object_get_int(json_object_array_get_idx(jcolor, 2));
|
||||
}
|
||||
|
||||
if (cfg.id.empty() || cfg.topic.empty()) {
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay] entry[%d] без id/topic — skip\n", i);
|
||||
continue;
|
||||
}
|
||||
|
||||
auto item = std::make_unique<MqttOverlayItem>(cfg, broker_, W, H);
|
||||
if (item->start()) items_.push_back(std::move(item));
|
||||
}
|
||||
json_object_put(root);
|
||||
std::fprintf(stderr, "[cfc/mqtt-overlay] %s: started %zu overlays\n",
|
||||
path.c_str(), items_.size());
|
||||
return static_cast<int>(items_.size());
|
||||
}
|
||||
|
||||
std::vector<cfc_overlay_t*> MqttOverlayManager::overlay_handles() const
|
||||
{
|
||||
std::vector<cfc_overlay_t*> v;
|
||||
v.reserve(items_.size());
|
||||
for (auto& i : items_) v.push_back(i->overlay());
|
||||
return v;
|
||||
}
|
||||
|
||||
void MqttOverlayManager::clear()
|
||||
{
|
||||
items_.clear();
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
@@ -0,0 +1,45 @@
|
||||
/* C wrapper для MqttOverlayManager (Phase 11b). */
|
||||
|
||||
#include "../../include/cuframes_composer/composer.h"
|
||||
#include "../../include/cuframes_composer/cpp/mqtt_overlay.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace {
|
||||
std::unique_ptr<cfc::MqttOverlayManager> g_mgr;
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
int cfc_mqtt_overlays_load(cfc_composer_t* composer,
|
||||
const char* path,
|
||||
const char* mqtt_host, int mqtt_port,
|
||||
const char* mqtt_user, const char* mqtt_pass,
|
||||
int frame_w, int frame_h)
|
||||
{
|
||||
if (!composer || !path) return -1;
|
||||
|
||||
cfc::MqttBrokerCfg br;
|
||||
if (mqtt_host) br.host = mqtt_host;
|
||||
if (mqtt_port > 0) br.port = mqtt_port;
|
||||
if (mqtt_user) br.username = mqtt_user;
|
||||
if (mqtt_pass) br.password = mqtt_pass;
|
||||
|
||||
g_mgr = std::make_unique<cfc::MqttOverlayManager>(br);
|
||||
int n = g_mgr->load_from_file(path, frame_w, frame_h);
|
||||
if (n <= 0) {
|
||||
g_mgr.reset();
|
||||
return n;
|
||||
}
|
||||
for (cfc_overlay_t* ov : g_mgr->overlay_handles()) {
|
||||
cfc_composer_add_overlay(composer, ov);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
void cfc_mqtt_overlays_stop(void)
|
||||
{
|
||||
g_mgr.reset();
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,110 @@
|
||||
/* SourcePool — реализация (Phase 11b). */
|
||||
|
||||
#include "../../include/cuframes_composer/cpp/source_pool.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
static std::int64_t now_ms_mono()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
SourcePool::~SourcePool()
|
||||
{
|
||||
for (auto& e : entries_) {
|
||||
if (e->source) cfc_source_destroy(e->source);
|
||||
}
|
||||
}
|
||||
|
||||
int SourcePool::add(const std::string& key,
|
||||
const std::string& fcam,
|
||||
int priority,
|
||||
const std::vector<std::string>& zones,
|
||||
const SubscribeOpts& opts)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
|
||||
/* duplicate guard */
|
||||
for (auto& e : entries_) {
|
||||
if (e->cuframes_key == key) {
|
||||
e->frigate_camera = fcam;
|
||||
e->priority = priority;
|
||||
e->required_zones = zones;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
auto e = std::make_unique<PoolEntry>();
|
||||
e->cuframes_key = key;
|
||||
e->frigate_camera = fcam;
|
||||
e->priority = priority;
|
||||
e->required_zones = zones;
|
||||
e->last_motion_ms.store(0);
|
||||
|
||||
char consumer_name[64];
|
||||
std::snprintf(consumer_name, sizeof(consumer_name), "%s-%zu",
|
||||
opts.consumer_prefix.c_str(), entries_.size());
|
||||
|
||||
cfc_source_config_t cfg{};
|
||||
cfg.key = e->cuframes_key.c_str();
|
||||
cfg.consumer_name = consumer_name;
|
||||
cfg.cuda_device = opts.cuda_device;
|
||||
cfg.reconnect_min_ms = opts.reconnect_min_ms;
|
||||
cfg.reconnect_max_ms = opts.reconnect_max_ms;
|
||||
cfg.stale_threshold_ms = opts.stale_threshold_ms;
|
||||
cfg.dead_threshold_ms = opts.dead_threshold_ms;
|
||||
|
||||
if (cfc_source_create(&cfg, &e->source) != 0) {
|
||||
std::fprintf(stderr, "[cfc/pool] subscribe '%s' failed — будет blackout\n",
|
||||
key.c_str());
|
||||
e->source = nullptr;
|
||||
}
|
||||
int idx = static_cast<int>(entries_.size());
|
||||
entries_.push_back(std::move(e));
|
||||
return idx;
|
||||
}
|
||||
|
||||
PoolEntry* SourcePool::by_key(const std::string& key)
|
||||
{
|
||||
for (auto& e : entries_) {
|
||||
if (e->cuframes_key == key) return e.get();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
PoolEntry* SourcePool::by_frigate_camera(const std::string& fcam)
|
||||
{
|
||||
for (auto& e : entries_) {
|
||||
if (e->frigate_camera == fcam) return e.get();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void SourcePool::motion_pulse(const std::string& frigate_camera,
|
||||
const std::vector<std::string>& current_zones)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
std::int64_t now = now_ms_mono();
|
||||
for (auto& e : entries_) {
|
||||
if (e->frigate_camera != frigate_camera) continue;
|
||||
/* Zone-filter — пропускаем если есть required_zones и не пересекаются. */
|
||||
if (!e->required_zones.empty()) {
|
||||
bool match = false;
|
||||
for (auto& cz : current_zones) {
|
||||
if (std::find(e->required_zones.begin(),
|
||||
e->required_zones.end(), cz) != e->required_zones.end()) {
|
||||
match = true; break;
|
||||
}
|
||||
}
|
||||
if (!match) continue;
|
||||
}
|
||||
e->last_motion_ms.store(now);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
@@ -0,0 +1,172 @@
|
||||
/* Template loader — реализация (Phase 11b). */
|
||||
|
||||
#include "../../include/cuframes_composer/cpp/template_loader.hpp"
|
||||
|
||||
#include <json-c/json.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
|
||||
namespace cfc {
|
||||
|
||||
namespace {
|
||||
|
||||
int json_int(struct json_object* obj, const char* key, int def)
|
||||
{
|
||||
struct json_object* v;
|
||||
if (!json_object_object_get_ex(obj, key, &v)) return def;
|
||||
return json_object_get_int(v);
|
||||
}
|
||||
|
||||
const char* json_str(struct json_object* obj, const char* key)
|
||||
{
|
||||
struct json_object* v;
|
||||
if (!json_object_object_get_ex(obj, key, &v)) return nullptr;
|
||||
return json_object_get_string(v);
|
||||
}
|
||||
|
||||
CellRole parse_role(const char* s)
|
||||
{
|
||||
if (s && std::string(s) == "widget") return CellRole::Widget;
|
||||
return CellRole::Camera;
|
||||
}
|
||||
|
||||
bool parse_template(struct json_object* jt, LayoutTemplate& out)
|
||||
{
|
||||
const char* name = json_str(jt, "name");
|
||||
if (!name) return false;
|
||||
out.name = name;
|
||||
out.priority = json_int(jt, "priority", 0);
|
||||
|
||||
struct json_object* jcells;
|
||||
if (!json_object_object_get_ex(jt, "cells", &jcells) ||
|
||||
!json_object_is_type(jcells, json_type_array)) return false;
|
||||
int n = static_cast<int>(json_object_array_length(jcells));
|
||||
for (int i = 0; i < n; i++) {
|
||||
struct json_object* jc = json_object_array_get_idx(jcells, i);
|
||||
if (!jc) continue;
|
||||
CellTemplate c;
|
||||
c.col = json_int(jc, "col", 0);
|
||||
c.row = json_int(jc, "row", 0);
|
||||
c.cs = json_int(jc, "cs", 1);
|
||||
c.rs = json_int(jc, "rs", 1);
|
||||
c.role = parse_role(json_str(jc, "role"));
|
||||
c.order = json_int(jc, "order", 0);
|
||||
const char* w = json_str(jc, "widget");
|
||||
if (w) c.widget = w;
|
||||
/* bounds */
|
||||
if (c.cs < 1 || c.rs < 1 || c.col < 0 || c.row < 0 ||
|
||||
c.col + c.cs > kGridCols || c.row + c.rs > kGridRows) {
|
||||
std::fprintf(stderr, "[cfc/loader] '%s' cell[%d] outside 8×8 — skip\n",
|
||||
name, i);
|
||||
continue;
|
||||
}
|
||||
out.cells.push_back(std::move(c));
|
||||
}
|
||||
return !out.cells.empty();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int load_templates_from_file(const std::string& path, std::vector<LayoutTemplate>& out)
|
||||
{
|
||||
std::ifstream f(path);
|
||||
if (!f.is_open()) {
|
||||
std::fprintf(stderr, "[cfc/loader] %s: open failed\n", path.c_str());
|
||||
return -3;
|
||||
}
|
||||
std::stringstream ss;
|
||||
ss << f.rdbuf();
|
||||
std::string buf = ss.str();
|
||||
|
||||
struct json_object* root = json_tokener_parse(buf.c_str());
|
||||
if (!root) {
|
||||
std::fprintf(stderr, "[cfc/loader] %s: JSON parse failed\n", path.c_str());
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct json_object* jtpls;
|
||||
if (!json_object_object_get_ex(root, "templates", &jtpls) ||
|
||||
!json_object_is_type(jtpls, json_type_array)) {
|
||||
std::fprintf(stderr, "[cfc/loader] %s: 'templates' missing\n", path.c_str());
|
||||
json_object_put(root);
|
||||
return -2;
|
||||
}
|
||||
int n = static_cast<int>(json_object_array_length(jtpls));
|
||||
std::vector<LayoutTemplate> tmp;
|
||||
for (int i = 0; i < n; i++) {
|
||||
struct json_object* jt = json_object_array_get_idx(jtpls, i);
|
||||
LayoutTemplate t;
|
||||
if (parse_template(jt, t)) tmp.push_back(std::move(t));
|
||||
}
|
||||
json_object_put(root);
|
||||
if (tmp.empty()) {
|
||||
std::fprintf(stderr, "[cfc/loader] %s: no valid templates\n", path.c_str());
|
||||
return -2;
|
||||
}
|
||||
out = std::move(tmp);
|
||||
std::fprintf(stderr, "[cfc/loader] %s: loaded %zu templates\n",
|
||||
path.c_str(), out.size());
|
||||
return static_cast<int>(out.size());
|
||||
}
|
||||
|
||||
std::vector<LayoutTemplate> builtin_templates()
|
||||
{
|
||||
std::vector<LayoutTemplate> v;
|
||||
|
||||
/* tpl_1: одна камера во весь экран. */
|
||||
{
|
||||
LayoutTemplate t; t.name = "tpl_1"; t.priority = 0;
|
||||
t.cells.push_back({0, 0, 8, 8, CellRole::Camera, 0, ""});
|
||||
v.push_back(std::move(t));
|
||||
}
|
||||
/* tpl_4: quad 2×2 — 4 камеры 16:9. */
|
||||
{
|
||||
LayoutTemplate t; t.name = "tpl_4"; t.priority = 0;
|
||||
t.cells.push_back({0, 0, 4, 4, CellRole::Camera, 0, ""});
|
||||
t.cells.push_back({4, 0, 4, 4, CellRole::Camera, 1, ""});
|
||||
t.cells.push_back({0, 4, 4, 4, CellRole::Camera, 2, ""});
|
||||
t.cells.push_back({4, 4, 4, 4, CellRole::Camera, 3, ""});
|
||||
v.push_back(std::move(t));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/* ── Global registry ─────────────────────────────────────────────────── */
|
||||
namespace {
|
||||
std::mutex g_reg_mu;
|
||||
std::vector<LayoutTemplate> g_registry;
|
||||
|
||||
void ensure_registry_locked()
|
||||
{
|
||||
if (g_registry.empty()) g_registry = builtin_templates();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
const std::vector<LayoutTemplate>& current_templates()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_reg_mu);
|
||||
ensure_registry_locked();
|
||||
return g_registry;
|
||||
}
|
||||
|
||||
void set_current_templates(std::vector<LayoutTemplate> new_templates)
|
||||
{
|
||||
if (new_templates.empty()) return;
|
||||
std::lock_guard<std::mutex> lk(g_reg_mu);
|
||||
g_registry = std::move(new_templates);
|
||||
}
|
||||
|
||||
int load_into_current(const std::string& path)
|
||||
{
|
||||
std::vector<LayoutTemplate> v;
|
||||
int r = load_templates_from_file(path, v);
|
||||
if (r > 0) {
|
||||
set_current_templates(std::move(v));
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
@@ -0,0 +1,24 @@
|
||||
/* WidgetCell — реализация (Phase 11b MVP).
|
||||
* Тёмный fill + label с именем widget'а в углу через LabelDecoration.
|
||||
*
|
||||
* Сам label-overlay создаётся при Layout::apply_template и добавляется как
|
||||
* decoration. Здесь только content — фон cell.
|
||||
*/
|
||||
|
||||
#include "../../include/cuframes_composer/cpp/widget_cell.hpp"
|
||||
#include "../../include/cuframes_composer/cugrid.h"
|
||||
|
||||
namespace cfc {
|
||||
|
||||
void WidgetCell::draw_content(CUstream stream, NV12Ref& dst)
|
||||
{
|
||||
if (geom_.empty()) return;
|
||||
/* Тёмно-серый Y=40, UV=128 (нейтральный). */
|
||||
cfc_cugrid_fill_nv12(
|
||||
stream,
|
||||
dst.y_ptr, dst.pitch_y, dst.uv_ptr, dst.pitch_uv,
|
||||
geom_.x, geom_.y, geom_.w, geom_.h,
|
||||
40, 128, 128, 255);
|
||||
}
|
||||
|
||||
} // namespace cfc
|
||||
+12
-3
@@ -92,13 +92,14 @@ static void parse_event(cfc_frigate_mqtt_t *f, const char *payload)
|
||||
const char *type = jtype ? json_object_get_string(jtype) : "update";
|
||||
|
||||
struct json_object *jcam = NULL, *jid = NULL, *jlabel = NULL, *jbox = NULL,
|
||||
*jft = NULL, *jzones = NULL;
|
||||
*jft = NULL, *jzones = NULL, *jscore = NULL;
|
||||
json_object_object_get_ex(jafter, "camera", &jcam);
|
||||
json_object_object_get_ex(jafter, "id", &jid);
|
||||
json_object_object_get_ex(jafter, "label", &jlabel);
|
||||
json_object_object_get_ex(jafter, "box", &jbox);
|
||||
json_object_object_get_ex(jafter, "frame_time", &jft);
|
||||
json_object_object_get_ex(jafter, "current_zones", &jzones);
|
||||
json_object_object_get_ex(jafter, "score", &jscore);
|
||||
|
||||
if (!jcam || !jid) { json_object_put(root); return; }
|
||||
const char *camera = json_object_get_string(jcam);
|
||||
@@ -153,6 +154,7 @@ static void parse_event(cfc_frigate_mqtt_t *f, const char *payload)
|
||||
|
||||
const char *label = jlabel ? json_object_get_string(jlabel) : "";
|
||||
double frame_time = jft ? json_object_get_double(jft) : 0.0;
|
||||
float score = jscore ? (float)json_object_get_double(jscore) : -1.0f;
|
||||
|
||||
/* Zone-filter overlay'я (current_zones уже распарсены выше). Отсев
|
||||
* street-флуда — Frigate 0.17 не даёт native objects.filters.required_zones. */
|
||||
@@ -161,7 +163,7 @@ static void parse_event(cfc_frigate_mqtt_t *f, const char *payload)
|
||||
return;
|
||||
}
|
||||
|
||||
cfc_overlay_detbox_upsert(ov, event_id, label, x1, y1, x2, y2,
|
||||
cfc_overlay_detbox_upsert(ov, event_id, label, score, x1, y1, x2, y2,
|
||||
(int64_t)(frame_time * 1000));
|
||||
|
||||
json_object_put(root);
|
||||
@@ -228,8 +230,15 @@ int cfc_frigate_mqtt_create(const cfc_frigate_mqtt_config_t *cfg,
|
||||
atomic_init(&f->events_received, 0);
|
||||
atomic_init(&f->parse_errors, 0);
|
||||
|
||||
/* client_id уникальный per instance. now_ms() недостаточно — несколько
|
||||
* subscriber'ов создаются в один тик (frigate + yoloworld), получают
|
||||
* одинаковый client_id и mosquitto kick'aет old при connect нового
|
||||
* → reconnect loop. Добавляем статический counter для tie-break. */
|
||||
static _Atomic int instance_seq = 0;
|
||||
int seq = atomic_fetch_add(&instance_seq, 1);
|
||||
char client_id[64];
|
||||
snprintf(client_id, sizeof(client_id), "composer-frigate-%d", (int)now_ms());
|
||||
snprintf(client_id, sizeof(client_id), "composer-frigate-%d-%d",
|
||||
(int)now_ms(), seq);
|
||||
f->mosq = mosquitto_new(client_id, true, f);
|
||||
if (!f->mosq) goto fail;
|
||||
|
||||
|
||||
-275
@@ -1,275 +0,0 @@
|
||||
/* Layout-templates на 8×8 микро-сетке (Phase 11).
|
||||
*
|
||||
* Step 3: JSON-based templates + hot-reload через ZMQ. Built-in templates
|
||||
* остаются как fallback (если JSON-файл недоступен).
|
||||
*
|
||||
* Лицензия: LGPL-2.1+
|
||||
*/
|
||||
|
||||
#include "../include/cuframes_composer/layouts.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <json-c/json.h>
|
||||
#include <pthread.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static cfc_layout_t g_layouts[64];
|
||||
static int g_layouts_count = 0;
|
||||
static char g_loaded_path[512] = {0};
|
||||
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static void recount_camera_cells(cfc_layout_t *l)
|
||||
{
|
||||
int n = 0;
|
||||
for (int i = 0; i < l->nb_cells; i++) {
|
||||
if (l->cells[i].role == CFC_CELL_CAMERA) n++;
|
||||
}
|
||||
l->nb_camera_cells = n;
|
||||
}
|
||||
|
||||
static cfc_layout_t *push_layout(const char *name, int priority)
|
||||
{
|
||||
if (g_layouts_count >= (int)(sizeof(g_layouts) / sizeof(g_layouts[0]))) return NULL;
|
||||
cfc_layout_t *l = &g_layouts[g_layouts_count++];
|
||||
memset(l, 0, sizeof(*l));
|
||||
strncpy(l->name, name, sizeof(l->name) - 1);
|
||||
l->name[sizeof(l->name) - 1] = '\0';
|
||||
l->priority = priority;
|
||||
return l;
|
||||
}
|
||||
|
||||
static void push_cell(cfc_layout_t *l, int col, int row, int cs, int rs,
|
||||
cfc_cell_role_t role, int order, const char *widget)
|
||||
{
|
||||
if (l->nb_cells >= CFC_LAYOUT_MAX_CELLS) return;
|
||||
cfc_cell_t *c = &l->cells[l->nb_cells++];
|
||||
c->col = col; c->row = row;
|
||||
c->cs = cs; c->rs = rs;
|
||||
c->role = role;
|
||||
c->order = order;
|
||||
if (widget) {
|
||||
strncpy(c->widget, widget, sizeof(c->widget) - 1);
|
||||
c->widget[sizeof(c->widget) - 1] = '\0';
|
||||
} else {
|
||||
c->widget[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* Built-in fallback templates — используются если JSON не загружен. */
|
||||
static void load_builtin_layouts_locked(void)
|
||||
{
|
||||
g_layouts_count = 0;
|
||||
|
||||
cfc_layout_t *l;
|
||||
|
||||
/* tpl_1 — одна камера во весь экран. */
|
||||
l = push_layout("tpl_1", 0);
|
||||
push_cell(l, 0, 0, 8, 8, CFC_CELL_CAMERA, 0, NULL);
|
||||
recount_camera_cells(l);
|
||||
|
||||
/* tpl_4 — quad 2×2 (cells 4×4 микроячейки, 960×540 16:9). */
|
||||
l = push_layout("tpl_4", 0);
|
||||
push_cell(l, 0, 0, 4, 4, CFC_CELL_CAMERA, 0, NULL);
|
||||
push_cell(l, 4, 0, 4, 4, CFC_CELL_CAMERA, 1, NULL);
|
||||
push_cell(l, 0, 4, 4, 4, CFC_CELL_CAMERA, 2, NULL);
|
||||
push_cell(l, 4, 4, 4, 4, CFC_CELL_CAMERA, 3, NULL);
|
||||
recount_camera_cells(l);
|
||||
}
|
||||
|
||||
static void ensure_loaded(void)
|
||||
{
|
||||
if (g_layouts_count > 0) return;
|
||||
load_builtin_layouts_locked();
|
||||
}
|
||||
|
||||
const cfc_layout_t *cfc_layout_find(const char *name)
|
||||
{
|
||||
if (!name) return NULL;
|
||||
pthread_mutex_lock(&g_mu);
|
||||
ensure_loaded();
|
||||
const cfc_layout_t *found = NULL;
|
||||
for (int i = 0; i < g_layouts_count; i++) {
|
||||
if (!strcmp(g_layouts[i].name, name)) { found = &g_layouts[i]; break; }
|
||||
}
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
return found;
|
||||
}
|
||||
|
||||
const cfc_layout_t *cfc_layout_all(int *out_count)
|
||||
{
|
||||
pthread_mutex_lock(&g_mu);
|
||||
ensure_loaded();
|
||||
if (out_count) *out_count = g_layouts_count;
|
||||
const cfc_layout_t *p = g_layouts;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
return p;
|
||||
}
|
||||
|
||||
void cfc_layout_to_pixels(const cfc_cell_t *cell, int W, int H,
|
||||
int *out_x, int *out_y, int *out_w, int *out_h)
|
||||
{
|
||||
if (!cell) return;
|
||||
int x = (cell->col * W) / CFC_GRID_COLS;
|
||||
int y = (cell->row * H) / CFC_GRID_ROWS;
|
||||
int w = (cell->cs * W) / CFC_GRID_COLS;
|
||||
int h = (cell->rs * H) / CFC_GRID_ROWS;
|
||||
x &= ~1; y &= ~1; w &= ~1; h &= ~1;
|
||||
if (x + w > W) w = W - x;
|
||||
if (y + h > H) h = H - y;
|
||||
if (out_x) *out_x = x;
|
||||
if (out_y) *out_y = y;
|
||||
if (out_w) *out_w = w;
|
||||
if (out_h) *out_h = h;
|
||||
}
|
||||
|
||||
/* ── JSON loader ──────────────────────────────────────────────────────── */
|
||||
|
||||
static int parse_role(const char *s)
|
||||
{
|
||||
if (!s) return CFC_CELL_CAMERA;
|
||||
if (!strcmp(s, "widget")) return CFC_CELL_WIDGET;
|
||||
return CFC_CELL_CAMERA;
|
||||
}
|
||||
|
||||
static int json_int(struct json_object *obj, const char *key, int def)
|
||||
{
|
||||
struct json_object *v;
|
||||
if (!json_object_object_get_ex(obj, key, &v)) return def;
|
||||
return json_object_get_int(v);
|
||||
}
|
||||
|
||||
static const char *json_str(struct json_object *obj, const char *key)
|
||||
{
|
||||
struct json_object *v;
|
||||
if (!json_object_object_get_ex(obj, key, &v)) return NULL;
|
||||
return json_object_get_string(v);
|
||||
}
|
||||
|
||||
static int parse_template(struct json_object *jt, cfc_layout_t *out)
|
||||
{
|
||||
memset(out, 0, sizeof(*out));
|
||||
const char *name = json_str(jt, "name");
|
||||
if (!name) {
|
||||
fprintf(stderr, "[cfc/layouts] template без 'name'\n");
|
||||
return -1;
|
||||
}
|
||||
strncpy(out->name, name, sizeof(out->name) - 1);
|
||||
out->priority = json_int(jt, "priority", 0);
|
||||
|
||||
struct json_object *jcells;
|
||||
if (!json_object_object_get_ex(jt, "cells", &jcells) ||
|
||||
!json_object_is_type(jcells, json_type_array)) {
|
||||
fprintf(stderr, "[cfc/layouts] template '%s' без 'cells'\n", name);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int nb = (int)json_object_array_length(jcells);
|
||||
if (nb > CFC_LAYOUT_MAX_CELLS) {
|
||||
fprintf(stderr, "[cfc/layouts] template '%s' has %d cells > max %d, truncated\n",
|
||||
name, nb, CFC_LAYOUT_MAX_CELLS);
|
||||
nb = CFC_LAYOUT_MAX_CELLS;
|
||||
}
|
||||
|
||||
for (int i = 0; i < nb; i++) {
|
||||
struct json_object *jc = json_object_array_get_idx(jcells, i);
|
||||
if (!jc) continue;
|
||||
cfc_cell_t *c = &out->cells[out->nb_cells];
|
||||
c->col = json_int(jc, "col", 0);
|
||||
c->row = json_int(jc, "row", 0);
|
||||
c->cs = json_int(jc, "cs", 1);
|
||||
c->rs = json_int(jc, "rs", 1);
|
||||
c->role = parse_role(json_str(jc, "role"));
|
||||
c->order = json_int(jc, "order", 0);
|
||||
const char *w = json_str(jc, "widget");
|
||||
if (w) {
|
||||
strncpy(c->widget, w, sizeof(c->widget) - 1);
|
||||
c->widget[sizeof(c->widget) - 1] = '\0';
|
||||
}
|
||||
/* Валидация bounds. */
|
||||
if (c->cs < 1 || c->rs < 1 ||
|
||||
c->col < 0 || c->row < 0 ||
|
||||
c->col + c->cs > CFC_GRID_COLS ||
|
||||
c->row + c->rs > CFC_GRID_ROWS) {
|
||||
fprintf(stderr, "[cfc/layouts] '%s' cell[%d] outside grid: col=%d row=%d cs=%d rs=%d — пропуск\n",
|
||||
name, i, c->col, c->row, c->cs, c->rs);
|
||||
continue;
|
||||
}
|
||||
out->nb_cells++;
|
||||
}
|
||||
recount_camera_cells(out);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_layout_load_file(const char *path)
|
||||
{
|
||||
if (!path) return -3;
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) {
|
||||
fprintf(stderr, "[cfc/layouts] %s: open failed: %s\n", path, strerror(errno));
|
||||
return -3;
|
||||
}
|
||||
fseek(f, 0, SEEK_END);
|
||||
long sz = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
if (sz <= 0 || sz > 1 << 20) { fclose(f); return -1; }
|
||||
char *buf = malloc(sz + 1);
|
||||
if (!buf) { fclose(f); return -1; }
|
||||
if ((long)fread(buf, 1, sz, f) != sz) { free(buf); fclose(f); return -1; }
|
||||
buf[sz] = '\0';
|
||||
fclose(f);
|
||||
|
||||
struct json_object *root = json_tokener_parse(buf);
|
||||
free(buf);
|
||||
if (!root) {
|
||||
fprintf(stderr, "[cfc/layouts] %s: JSON parse failed\n", path);
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct json_object *jtpls;
|
||||
if (!json_object_object_get_ex(root, "templates", &jtpls) ||
|
||||
!json_object_is_type(jtpls, json_type_array)) {
|
||||
fprintf(stderr, "[cfc/layouts] %s: 'templates' array missing\n", path);
|
||||
json_object_put(root);
|
||||
return -2;
|
||||
}
|
||||
int n = (int)json_object_array_length(jtpls);
|
||||
if (n <= 0) {
|
||||
fprintf(stderr, "[cfc/layouts] %s: 'templates' пуст\n", path);
|
||||
json_object_put(root);
|
||||
return -2;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_mu);
|
||||
int new_count = 0;
|
||||
cfc_layout_t tmp[64];
|
||||
for (int i = 0; i < n && new_count < (int)(sizeof(tmp)/sizeof(tmp[0])); i++) {
|
||||
struct json_object *jt = json_object_array_get_idx(jtpls, i);
|
||||
if (parse_template(jt, &tmp[new_count]) == 0) new_count++;
|
||||
}
|
||||
if (new_count > 0) {
|
||||
memcpy(g_layouts, tmp, sizeof(cfc_layout_t) * new_count);
|
||||
g_layouts_count = new_count;
|
||||
strncpy(g_loaded_path, path, sizeof(g_loaded_path) - 1);
|
||||
g_loaded_path[sizeof(g_loaded_path) - 1] = '\0';
|
||||
fprintf(stderr, "[cfc/layouts] %s: loaded %d templates\n", path, new_count);
|
||||
} else {
|
||||
fprintf(stderr, "[cfc/layouts] %s: no valid templates, keeping current\n", path);
|
||||
}
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
json_object_put(root);
|
||||
return new_count;
|
||||
}
|
||||
|
||||
int cfc_layout_reload(void)
|
||||
{
|
||||
if (!g_loaded_path[0]) return -1;
|
||||
return cfc_layout_load_file(g_loaded_path);
|
||||
}
|
||||
|
||||
const char *cfc_layout_loaded_path(void)
|
||||
{
|
||||
return g_loaded_path[0] ? g_loaded_path : NULL;
|
||||
}
|
||||
+214
-9
@@ -35,13 +35,20 @@
|
||||
typedef struct detbox_entry {
|
||||
char event_id[48]; /* "" = slot пустой */
|
||||
char label[16];
|
||||
float score; /* 0..1; <0 → не рисовать score */
|
||||
int x1, y1, x2, y2; /* raw detect coords */
|
||||
int64_t last_update_ms; /* для TTL */
|
||||
/* Cached label atlas. Rebuild только при изменении label_with_score_txt.
|
||||
* Atlas — RGBA на VRAM (того же color что и border, но через FT bitmap). */
|
||||
char rendered_text[32]; /* что сейчас в atlas'е ("car 87%") */
|
||||
CUdeviceptr text_atlas; /* 0 = нет cached */
|
||||
int text_w, text_h, text_pitch; /* RGBA buffer size */
|
||||
} detbox_entry_t;
|
||||
|
||||
typedef struct detbox_data {
|
||||
cfc_overlay_detbox_config_t cfg;
|
||||
char camera_key[64]; /* копия cfg.camera_key */
|
||||
char font_path_copy[128]; /* копия cfg.font_path */
|
||||
pthread_mutex_t mu;
|
||||
detbox_entry_t entries[CFC_DETBOX_MAX];
|
||||
int count;
|
||||
@@ -49,6 +56,11 @@ typedef struct detbox_data {
|
||||
* указатель caller'а, может быть stack/temp). */
|
||||
char required_zones[CFC_DETBOX_ZONE_MAX][CFC_DETBOX_ZONE_NAME];
|
||||
int required_zones_count;
|
||||
/* FreeType — opaque (FT_Library, FT_Face) — нужен <ft2build.h>.
|
||||
* Hold как void* чтобы не тянуть FT API в overlay.h. */
|
||||
void *ft_library; /* FT_Library */
|
||||
void *ft_face; /* FT_Face — NULL если нет font_path */
|
||||
int font_size_px;
|
||||
} detbox_data_t;
|
||||
|
||||
typedef struct png_data {
|
||||
@@ -537,6 +549,11 @@ int cfc_overlay_update_text(cfc_overlay_t *ov,
|
||||
td->cfg.y = cfg->y;
|
||||
td->cfg.extra_alpha = cfg->extra_alpha ? cfg->extra_alpha : 255;
|
||||
td->cfg.visible = cfg->visible;
|
||||
td->cfg.bg_alpha = cfg->bg_alpha;
|
||||
td->cfg.bg_y = cfg->bg_y;
|
||||
td->cfg.bg_u = cfg->bg_u;
|
||||
td->cfg.bg_v = cfg->bg_v;
|
||||
td->cfg.bg_pad = cfg->bg_pad;
|
||||
|
||||
if (need_rebuild) return text_rebuild_atlas(td);
|
||||
return 0;
|
||||
@@ -563,6 +580,28 @@ static int draw_text(cfc_overlay_t *ov, CUstream stream,
|
||||
|
||||
int x = t->cfg.x & ~1;
|
||||
int y = t->cfg.y & ~1;
|
||||
|
||||
/* Опциональный фон-подложка (для читаемости текста на любом фоне). */
|
||||
if (t->cfg.bg_alpha > 0) {
|
||||
int pad = t->cfg.bg_pad > 0 ? t->cfg.bg_pad : 8;
|
||||
pad &= ~1;
|
||||
int bx = x - pad, by = y - pad;
|
||||
int bw = t->width + 2 * pad, bh = t->height + 2 * pad;
|
||||
if (bx < 0) { bw += bx; bx = 0; }
|
||||
if (by < 0) { bh += by; by = 0; }
|
||||
if (bx + bw > frame_w) bw = frame_w - bx;
|
||||
if (by + bh > frame_h) bh = frame_h - by;
|
||||
bx &= ~1; by &= ~1; bw &= ~1; bh &= ~1;
|
||||
if (bw > 0 && bh > 0) {
|
||||
int by_v = t->cfg.bg_y ? t->cfg.bg_y : 16;
|
||||
int bu_v = t->cfg.bg_u ? t->cfg.bg_u : 128;
|
||||
int bv_v = t->cfg.bg_v ? t->cfg.bg_v : 128;
|
||||
cfc_cugrid_fill_nv12(stream, dst_y, pitch_y, dst_uv, pitch_uv,
|
||||
bx, by, bw, bh,
|
||||
by_v, bu_v, bv_v, t->cfg.bg_alpha);
|
||||
}
|
||||
}
|
||||
|
||||
return cfc_cugrid_blit_rgba_nv12(
|
||||
stream,
|
||||
dst_y, pitch_y, dst_uv, pitch_uv,
|
||||
@@ -573,6 +612,58 @@ static int draw_text(cfc_overlay_t *ov, CUstream stream,
|
||||
|
||||
/* ── DETECTION_BOXES (Phase 7) ────────────────────────────────────────── */
|
||||
|
||||
/* Re-render label atlas в VRAM для entry. Called с mutex'ом удерживаемым
|
||||
* caller'ом (mu локает entries access). Если label/score не изменились —
|
||||
* no-op. Возвращает 0 даже если FT нет (атлас просто не создаётся).
|
||||
*
|
||||
* Алгоритм: format → measure → render RGBA → upload в CUDA. Использует
|
||||
* существующие text_measure/text_render (общие с CFC_OVERLAY_TEXT).
|
||||
*
|
||||
* Color RGBA пишется белым с background pill цвета overlay (фон даст
|
||||
* контраст border-цвета, белый текст всегда читаемо). */
|
||||
static void detbox_rebuild_label_atlas(detbox_data_t *d, int slot)
|
||||
{
|
||||
if (!d->ft_face) return;
|
||||
FT_Face face = (FT_Face)d->ft_face;
|
||||
detbox_entry_t *e = &d->entries[slot];
|
||||
|
||||
/* Формат: "label NN%" (если score >= 0) или просто "label". */
|
||||
char txt[32];
|
||||
if (e->score >= 0.0f) {
|
||||
int pct = (int)(e->score * 100.0f + 0.5f);
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
snprintf(txt, sizeof(txt), "%s %d%%", e->label, pct);
|
||||
} else {
|
||||
snprintf(txt, sizeof(txt), "%s", e->label);
|
||||
}
|
||||
if (!strcmp(txt, e->rendered_text)) return; /* кеш свеж */
|
||||
|
||||
/* Measure */
|
||||
int w = 0, h = 0, ascent = 0;
|
||||
if (text_measure(face, txt, &w, &h, &ascent) != 0) return;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
unsigned char *cpu = calloc((size_t)w * h, 4);
|
||||
if (!cpu) return;
|
||||
/* Белый текст для контраста с pill background. */
|
||||
text_render(face, txt, cpu, w, h, ascent, 255, 255, 255);
|
||||
|
||||
/* Free old + allocate new — каждый раз. Размер может меняться. */
|
||||
if (e->text_atlas) { cuMemFree(e->text_atlas); e->text_atlas = 0; }
|
||||
if (cuMemAlloc(&e->text_atlas, (size_t)w * h * 4) != CUDA_SUCCESS) {
|
||||
free(cpu); return;
|
||||
}
|
||||
if (cuMemcpyHtoD(e->text_atlas, cpu, (size_t)w * h * 4) != CUDA_SUCCESS) {
|
||||
cuMemFree(e->text_atlas); e->text_atlas = 0;
|
||||
free(cpu); return;
|
||||
}
|
||||
free(cpu);
|
||||
e->text_w = w; e->text_h = h; e->text_pitch = w * 4;
|
||||
strncpy(e->rendered_text, txt, sizeof(e->rendered_text) - 1);
|
||||
e->rendered_text[sizeof(e->rendered_text) - 1] = '\0';
|
||||
}
|
||||
|
||||
|
||||
int cfc_overlay_create_detection_boxes(
|
||||
const cfc_overlay_detbox_config_t *cfg, cfc_overlay_t **out)
|
||||
{
|
||||
@@ -610,6 +701,30 @@ int cfc_overlay_create_detection_boxes(
|
||||
d->cfg.required_zones = NULL;
|
||||
d->cfg.required_zones_count = d->required_zones_count;
|
||||
|
||||
/* FreeType — опционально, для рендеринга label+score над bbox.
|
||||
* Если font_path не задан → text не рисуется (legacy behavior). */
|
||||
d->ft_library = NULL;
|
||||
d->ft_face = NULL;
|
||||
d->font_size_px = cfg->font_size > 0 ? cfg->font_size : 16;
|
||||
if (cfg->font_path) {
|
||||
strncpy(d->font_path_copy, cfg->font_path, sizeof(d->font_path_copy) - 1);
|
||||
d->cfg.font_path = d->font_path_copy;
|
||||
FT_Library lib = NULL;
|
||||
if (FT_Init_FreeType(&lib) == 0) {
|
||||
FT_Face face = NULL;
|
||||
if (FT_New_Face(lib, d->font_path_copy, 0, &face) == 0) {
|
||||
FT_Set_Pixel_Sizes(face, 0, (FT_UInt)d->font_size_px);
|
||||
d->ft_library = lib;
|
||||
d->ft_face = face;
|
||||
} else {
|
||||
FT_Done_FreeType(lib);
|
||||
fprintf(stderr, "[overlay] detbox: font %s не открыт\n",
|
||||
d->font_path_copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (d->cfg.label_bg_alpha <= 0) d->cfg.label_bg_alpha = 200;
|
||||
|
||||
*out = ov;
|
||||
return 0;
|
||||
}
|
||||
@@ -620,6 +735,21 @@ const char *cfc_overlay_detbox_camera_key(cfc_overlay_t *ov)
|
||||
return ov->u.detbox.camera_key;
|
||||
}
|
||||
|
||||
int cfc_overlay_detbox_set_cell_geom(cfc_overlay_t *ov,
|
||||
int cell_x, int cell_y,
|
||||
int cell_w, int cell_h)
|
||||
{
|
||||
if (!ov || ov->type != CFC_OVERLAY_DETECTION_BOXES) return -1;
|
||||
detbox_data_t *d = &ov->u.detbox;
|
||||
pthread_mutex_lock(&d->mu);
|
||||
d->cfg.cell_x = cell_x;
|
||||
d->cfg.cell_y = cell_y;
|
||||
d->cfg.cell_w = cell_w;
|
||||
d->cfg.cell_h = cell_h;
|
||||
pthread_mutex_unlock(&d->mu);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cfc_overlay_detbox_match_zones(cfc_overlay_t *ov,
|
||||
const char *const *current_zones,
|
||||
int n)
|
||||
@@ -639,6 +769,7 @@ int cfc_overlay_detbox_match_zones(cfc_overlay_t *ov,
|
||||
|
||||
int cfc_overlay_detbox_upsert(cfc_overlay_t *ov, const char *event_id,
|
||||
const char *label,
|
||||
float score,
|
||||
int x1, int y1, int x2, int y2,
|
||||
int64_t frame_time_ms)
|
||||
{
|
||||
@@ -672,12 +803,17 @@ int cfc_overlay_detbox_upsert(cfc_overlay_t *ov, const char *event_id,
|
||||
strncpy(d->entries[slot].label, label,
|
||||
sizeof(d->entries[slot].label) - 1);
|
||||
}
|
||||
d->entries[slot].score = score;
|
||||
d->entries[slot].x1 = x1;
|
||||
d->entries[slot].y1 = y1;
|
||||
d->entries[slot].x2 = x2;
|
||||
d->entries[slot].y2 = y2;
|
||||
d->entries[slot].last_update_ms = now_ms();
|
||||
|
||||
/* НЕ rebuild в upsert: upsert вызывается из MQTT thread который не
|
||||
* имеет CUDA context (cuMemAlloc → ERR_INVALID_CONTEXT err=201). Atlas
|
||||
* lazily rebuilt в draw из main composer thread. */
|
||||
|
||||
pthread_mutex_unlock(&d->mu);
|
||||
return 0;
|
||||
}
|
||||
@@ -709,22 +845,49 @@ static int draw_detection_boxes(cfc_overlay_t *ov,
|
||||
detbox_data_t *d = &ov->u.detbox;
|
||||
int64_t cutoff = now_ms() - d->cfg.stale_ms;
|
||||
|
||||
/* Snapshot active boxes под mutex'ом — короткая критическая секция. */
|
||||
typedef struct { int x1, y1, x2, y2; } box_t;
|
||||
/* Snapshot active boxes под mutex'ом — короткая критическая секция.
|
||||
* Также захватываем text_atlas pointer + size (read-only — упомянуть в
|
||||
* thread-safety: atlas остаётся валидным пока entry не disposed,
|
||||
* а entry disposed только в upsert/end/destroy под mutex'ом — и мы тут
|
||||
* не держим mutex после snapshot. Если в этот промежуток какой-то
|
||||
* upsert rebuild'ит атлас (cuMemFree + alloc) — мы будем читать
|
||||
* освобождённую память. Делаем под mutex'ом). */
|
||||
typedef struct {
|
||||
int x1, y1, x2, y2;
|
||||
CUdeviceptr text_atlas;
|
||||
int text_w, text_h, text_pitch;
|
||||
} box_t;
|
||||
box_t snap[CFC_DETBOX_MAX];
|
||||
int snap_n = 0;
|
||||
pthread_mutex_lock(&d->mu);
|
||||
for (int i = 0; i < CFC_DETBOX_MAX; i++) {
|
||||
if (!d->entries[i].event_id[0]) continue;
|
||||
if (d->entries[i].last_update_ms < cutoff) continue; /* TTL expired */
|
||||
snap[snap_n++] = (box_t){
|
||||
.x1 = d->entries[i].x1, .y1 = d->entries[i].y1,
|
||||
.x2 = d->entries[i].x2, .y2 = d->entries[i].y2,
|
||||
};
|
||||
/* Lazy rebuild label atlas — выполняется в main composer thread
|
||||
* где CUDA context активен (upsert thread его не имеет). No-op если
|
||||
* label/score не изменились. */
|
||||
detbox_rebuild_label_atlas(d, i);
|
||||
snap[snap_n].x1 = d->entries[i].x1;
|
||||
snap[snap_n].y1 = d->entries[i].y1;
|
||||
snap[snap_n].x2 = d->entries[i].x2;
|
||||
snap[snap_n].y2 = d->entries[i].y2;
|
||||
snap[snap_n].text_atlas = d->entries[i].text_atlas;
|
||||
snap[snap_n].text_w = d->entries[i].text_w;
|
||||
snap[snap_n].text_h = d->entries[i].text_h;
|
||||
snap[snap_n].text_pitch = d->entries[i].text_pitch;
|
||||
snap_n++;
|
||||
}
|
||||
pthread_mutex_unlock(&d->mu);
|
||||
/* Удерживаем mutex до конца draw — atlas чтение в blit_rgba_nv12
|
||||
* issue'ит CUDA копирование, после launch host может отпустить.
|
||||
* Но launch синхронный wrt host — поэтому safe отпустить после
|
||||
* последнего launch. Делаем так: snapshot done → unlock. blit launches
|
||||
* (после) идут через CUstream — async wrt host, но CUDA hold'ает
|
||||
* input atlas pointer до completion. Между launch и atlas free есть race.
|
||||
* Решение: НЕ освобождать atlas в upsert, только аллоцировать новый
|
||||
* (старый утечёт). Альтернатива — synchronize stream перед free.
|
||||
* На MVP оставляем mutex до конца — простота важнее. */
|
||||
|
||||
if (snap_n == 0) return 0;
|
||||
if (snap_n == 0) { pthread_mutex_unlock(&d->mu); return 0; }
|
||||
|
||||
/* Coordinate mapping: detect → cell. Линейный scale + offset.
|
||||
* detect_w/h не кэшированы — берутся из cfg в момент draw'а, layout
|
||||
@@ -771,7 +934,39 @@ static int draw_detection_boxes(cfc_overlay_t *ov,
|
||||
x + w - tt, y + tt, tt, h - 2 * tt,
|
||||
d->cfg.color_y, d->cfg.color_u, d->cfg.color_v,
|
||||
d->cfg.alpha);
|
||||
|
||||
/* Label + score pill — над bbox (или внутри, если bbox у верха frame'а). */
|
||||
if (snap[i].text_atlas && snap[i].text_w > 0 && snap[i].text_h > 0) {
|
||||
int pad = 4;
|
||||
int pill_w = snap[i].text_w + 2 * pad;
|
||||
int pill_h = snap[i].text_h + 2 * pad;
|
||||
int pill_x = x; /* выравниваем по левому краю bbox */
|
||||
int pill_y = y - pill_h; /* над верхним краем */
|
||||
if (pill_y < 0) pill_y = y + tt; /* fallback внутрь top */
|
||||
pill_x &= ~1; pill_y &= ~1;
|
||||
int eff_w = pill_w & ~1;
|
||||
int eff_h = pill_h & ~1;
|
||||
if (pill_x + eff_w > frame_w) eff_w = (frame_w - pill_x) & ~1;
|
||||
if (pill_y + eff_h > frame_h) eff_h = (frame_h - pill_y) & ~1;
|
||||
if (eff_w > 0 && eff_h > 0) {
|
||||
/* Pill background — цвет border'а (зелёный/magenta) полу-непрозрачный. */
|
||||
cfc_cugrid_fill_nv12(stream, dst_y, pitch_y, dst_uv, pitch_uv,
|
||||
pill_x, pill_y, eff_w, eff_h,
|
||||
d->cfg.color_y, d->cfg.color_u, d->cfg.color_v,
|
||||
d->cfg.label_bg_alpha);
|
||||
/* Text — белый RGBA → blend в NV12. */
|
||||
int text_x = (pill_x + pad) & ~1;
|
||||
int text_y = (pill_y + pad) & ~1;
|
||||
cfc_cugrid_blit_rgba_nv12(stream, dst_y, pitch_y, dst_uv, pitch_uv,
|
||||
text_x, text_y,
|
||||
snap[i].text_atlas,
|
||||
snap[i].text_w, snap[i].text_h,
|
||||
snap[i].text_pitch,
|
||||
255);
|
||||
}
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&d->mu);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -816,7 +1011,17 @@ int cfc_overlay_destroy(cfc_overlay_t *ov)
|
||||
free(td->font_path_owned);
|
||||
}
|
||||
if (ov->type == CFC_OVERLAY_DETECTION_BOXES) {
|
||||
pthread_mutex_destroy(&ov->u.detbox.mu);
|
||||
detbox_data_t *d = &ov->u.detbox;
|
||||
/* Освободить cached text atlas'ы. */
|
||||
for (int i = 0; i < CFC_DETBOX_MAX; i++) {
|
||||
if (d->entries[i].text_atlas) {
|
||||
cuMemFree(d->entries[i].text_atlas);
|
||||
d->entries[i].text_atlas = 0;
|
||||
}
|
||||
}
|
||||
if (d->ft_face) FT_Done_Face((FT_Face)d->ft_face);
|
||||
if (d->ft_library) FT_Done_FreeType((FT_Library)d->ft_library);
|
||||
pthread_mutex_destroy(&d->mu);
|
||||
}
|
||||
free(ov);
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user