Compare commits

...

20 Commits

Author SHA1 Message Date
gx e57999b6b8 detbox: lazy atlas rebuild в draw (CUDA context fix)
Bug: cfc_overlay_detbox_upsert вызывался из MQTT thread который не имеет
CUDA primary context active. cuMemAlloc → CUDA_ERROR_INVALID_CONTEXT (201),
text_atlas всегда оставался 0 → подпись не рисовалась.

Fix: rebuild_label_atlas вызывается lazily из draw_detection_boxes
(main composer thread с активным CUDA context). Текст cached по
rendered_text — rebuild только при изменении label/score.

Verify (live на cfc-grid): atlas '...' успешно аллоцируется, snap
видит правильный atlas pointer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 21:56:38 +01:00
gx 265c5c9503 detbox: label + score pill над bbox (FreeType)
Раньше detbox рисовал только рамку. Теперь на каждое active событие
отображает "label NN%" в полупрозрачной pill цвета рамки над верхним
краем bbox.

include/cuframes_composer/overlay.h:
- cfc_overlay_detbox_config_t: + font_path, font_size, label_bg_alpha
- cfc_overlay_detbox_upsert: + float score parameter

src/overlay.c:
- detbox_entry_t: + score, rendered_text (кеш), text_atlas/w/h/pitch
- detbox_data_t: + ft_library, ft_face, font_path_copy, font_size_px
- cfc_overlay_create_detection_boxes: opens FT face если font_path задан
- cfc_overlay_destroy: free cached atlas'ы + FT face/library
- detbox_rebuild_label_atlas: format "label NN%" → FT render → VRAM upload
  (reuse text_measure / text_render из CFC_OVERLAY_TEXT)
- upsert: вызывает rebuild при изменении label/score (кеш по rendered_text)
- draw_detection_boxes: snap расширен под text_atlas; после border рисует
  pill bg (fill_nv12 цветом рамки) + текст (blit_rgba_nv12, белый)
- mutex hold всю draw — atlas чтение должно быть atomic против upsert

src/frigate_mqtt.c:
- parse after.score → передаём в upsert (Frigate & yoloworld envelope
  совместимы: оба содержат score)

examples/grid_record.c:
- 4 frigate detbox: font_path=/fonts/DejaVuSans-Bold.ttf, font_size=16
- 4 yw detbox: то же — magenta pill с белым текстом

Live: cfc-grid healthy 25 fps, image gx/cuframes-composer:0.11b-step4
deployed. Видно на TV при первом detection event.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-18 03:08:14 +01:00
gx 7bd8184159 frigate_mqtt: unique client_id per instance (несколько subscriber'ов)
Bug: client_id format "composer-frigate-%d" использовал только now_ms().
Когда создаются 2 subscriber'а подряд (frigate + yoloworld) — оба
получают один client_id за тот же мс, mosquitto kick'ает старый при
connect нового → infinite reconnect loop, ни один не получает events
стабильно.

Fix: static _Atomic int instance_seq counter — tie-break суффикс.
Format: "composer-frigate-%d-%d".

Verify mosquitto logs:
  composer-frigate--2117451292-0  (frigate subscriber)
  composer-frigate--2117451292-1  (yoloworld subscriber)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 13:50:28 +01:00
gx ce7aa6cb8d grid_record: добавить --yw-mqtt + --yw-topic для magenta bbox от YOLO-World
Параллельный MQTT subscriber на отдельный topic (default yoloworld/events)
от yolo-world-detector сервиса. Использует те же --detection-cell что
Frigate, но рендерит bbox magenta цветом (Y=105 U=212 V=234) вместо
зелёного (Y=210 U=50 V=100).

На одной картинке композитор может одновременно показать:
- зелёный bbox от Frigate (person/car/...)
- magenta bbox от yolo-world (fox/dog/drone/...)

Backward compat 100% — без --yw-mqtt никаких изменений в поведении.

Архитектурно:
- Второй cfc_frigate_mqtt_t (тот же envelope schema через
  cfc_frigate_mqtt parser — yolo-world-detector публикует
  Frigate-compat events)
- Параллельный yw_detbox_overlays[] с magenta colors
- yolo-world subscriber не управляет motion-layout (composer=NULL),
  только Frigate шлёт motion-pulse'ы

Verify build:
  cmake --build build  # все 4 target'а (grid_record, simple_record,
                       # grid_record_cpp, cuframes_composer_static)

Live test пока через docker recreate в Phase 5 deploy, после того
как yolo-world publish'ит в реальный MQTT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 18:32:17 +01:00
gx e54d55371c templates.json: tpl_3 теперь 4 камеры + temp_chart-узкий
Было: tpl_3 = main + 2 preview + temp_chart (rs=4) + ha_chat.
При 4 active композитор выбирал tpl_3 (waste=1 vs tpl_4 waste=0 — wait,
tpl_3 имел 3 cam cells, при 4 active брал tpl_4=quad без виджетов).

Стало: tpl_3 = main + 3 preview + temp_chart (rs=2, низ-право) + ha_chat.
4 cam cells, при 4 active best-fit выбирает tpl_3 (waste=0, раньше в registry
чем tpl_4 → tie-breaker за tpl_3). Виджеты остаются видимыми.

Geometry validated: 36+4+4+4+4+12=64 микроячеек, no overlaps.

Hot-reloaded via reload_templates ZMQ в проде.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 19:17:56 +01:00
gx e76360dbc4 docs: руководства пользователя / разработчика / operations (RU+EN)
Полный комплект документации к Phase 11b:

  docs/ru/user.md        — для админа инсталляции (motion-mode, PTZ,
                            templates.json, mqtt_overlays.json, ZMQ verbs)
  docs/ru/developer.md   — архитектура (Cell / Layout / Decoration),
                            как добавить новый Cell/Decoration, ABI shim,
                            algorithms (best-fit + asymmetric hysteresis)
  docs/ru/operations.md  — build (host + jammy + incremental bake),
                            deploy, logs/telemetry, troubleshooting
                            (broken pipe, MQTT-overlay, motion-mode)
  docs/en/*.md           — английская версия всех трёх
  README.md              — переписан с overview + ссылками на docs/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 22:02:47 +01:00
gx b68d00604f Merge phase11b-cpp: C++ refactor композитора (Phase 11b)
13 commits объединяются в main. C++17 ООП-модель Cell / Layout /
Decoration через extern "C" ABI shim — старые callers работают
без изменений.

Ключевое:
  - cpp/cuda_raii.hpp: RAII обёртки (CudaBuffer/CudaStream), zero-copy
  - cpp/cell.hpp + camera_cell/widget_cell/blank_cell: иерархия cells
  - cpp/decoration.hpp + label_decoration/border_decoration: композиция
  - cpp/layout.hpp + template.hpp: 8×8 микро-сетка, JSON-templates
  - cpp/source_pool: pool + motion-state, by_frigate_camera lookup
  - cpp/composer: best-fit + asymmetric hysteresis (рост 0s, shrink 3s),
    fill свободных cells остальными drawable, manual PTZ override 60s
  - cpp/mqtt_overlay: generic MQTT-driven text overlays из JSON,
    подложка, anchors
  - composer_c_api.cpp + layouts_c_api.cpp: extern "C" wrappers
  - Удалены: src/composer.c, src/layouts.c
  - Detection box bbox следует за камерой при смене layout

В проде: gx/cuframes-composer:0.11b-step1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 19:03:17 +01:00
gx 3730c65a1e mqtt_overlays: убраны assist_question / assist_response
Conversation events HA Assist не приходят (архитектурное ограничение).
Чтобы placeholders ❯ Вы: ... и ✎ HA: ... не висели на экране без
данных — overlay'и убраны до решения вопроса с источником диалога.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 19:01:31 +01:00
gx a8ce3f1ccb mqtt_overlays.json: добавлены HA Assist question / response overlays
Подписка на assist/conversation/text + /response (plain-text retained,
schema см. home-assistant/.../assist_mqtt.md).

Позиция: левый-нижний угол стопкой —
  🤖 ответ внизу (margin_y=32)
  🗣 вопрос выше (margin_y=78)

Полупрозрачная подложка (bg_alpha=180), placeholder с эмодзи пока
turn'а не было.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 15:22:04 +01:00
gx d8e69c6392 Phase 11b: detection-box bbox следует за камерой при смене layout
User: "при движении объект оборачивается в рамку, но функционал не
учитывает что сетки могут переключаться и координаты ячеек меняются".

Был баг: detbox-overlay хранил cell_x/y/w/h из --detection-cell CLI
(заданы при старте), при смене layout рамки рисовались по старым
координатам — мимо камеры.

Изменения:
  - overlay.h/.c: новый API cfc_overlay_detbox_set_cell_geom(ov,x,y,w,h).
    Mutex-защищённое обновление detbox config'а — composer вызывает
    перед каждым draw.
  - CameraCell: добавлено поле source_key (хранит cuframes-key камеры,
    рендерящейся в этом cell). Layout::apply передаёт его из pool entry.
  - Layout::find_camera_cell_rect(key) — возвращает Rect текущей cell
    для камеры с заданным cuframes-key (или nullptr если её нет в layout).
  - SourcePool::by_frigate_camera(name) — lookup pool-entry по
    Frigate-camera-key (frigate event'ы приходят с этим именем).
  - Composer::compose_frame: перед draw каждого DETECTION_BOXES overlay'я
    — lookup frigate→cuframes_key→layout cell rect, обновляет detbox geom.
    Если камера не в layout сейчас — cell_w/h=0, detbox draw skip'ает.

Теперь bbox от Frigate переезжает за камерой:
  - tpl_1 → bbox в full screen 1920×1080
  - tpl_3 → bbox в main 1440×810
  - tpl_4 → bbox в quad ячейке 960×540
  - камера не в layout → bbox скрыт

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:25:25 +01:00
gx 88fa73f922 Phase 11b: подложка для text overlay + сразу-видимый placeholder
User: "у него есть полупрозрачная подложка? у него правильный z-order?"

Z-order был ок (overlays draw'аются после layout.render). Но без bg
текст плохо читался на пёстром кадре — и до прихода MQTT overlay был
visible=0 (не виден вообще).

Изменения:
  - overlay.h: cfc_overlay_text_config_t расширена bg_alpha / bg_y/u/v / bg_pad.
    bg_alpha=0 — фон отключён (default).
  - overlay.c draw_text: если bg_alpha>0, перед blit'ом текста рисуем fill
    rect (atlas_w+2*pad) × (atlas_h+2*pad) с заданным цветом и alpha.
  - overlay.c update_text: пробрасывает bg-поля при апдейте.
  - mqtt_overlay: MqttOverlayCfg + JSON loader научились читать bg_alpha,
    bg_y/u/v, bg_pad, placeholder. Default bg = чёрный 160 alpha, pad 10.
  - MqttOverlayItem::start: overlay сразу visible=1 с placeholder (default "—"),
    reposition_overlay вызывается до получения MQTT — placeholder
    позиционируется в anchor сразу.

User'у теперь видна тёмная подложка с текстом в правом-нижнем углу даже
если sensor молчит.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:19:29 +01:00
gx 75271436f7 Phase 11b: MQTT-overlays конфигурируются через JSON (вместо --temp-topic)
User: "оверлеев которые выводят какую-то инфу из MQTT может быть
бесконечно много. Надо делать настраиваемым через json/yaml конфиг".

Заменили захардкоженный TempMqttOverlay на generic MqttOverlay:

  - cpp/mqtt_overlay.hpp/.cpp: класс MqttOverlayItem (один topic + один
    text overlay) + MqttOverlayManager (контейнер). Загрузка из JSON.
  - cpp/mqtt_overlay_c_api.cpp: extern "C" обёртка для grid_record.c.
  - docker/mqtt_overlays.json: default config с temp_outside примером.
  - grid_record.c: --mqtt-overlays=PATH (заменил --temp-topic).
  - src/CMakeLists.txt: temp_overlay* удалены, mqtt_overlay* добавлены.

JSON schema:
  {
    "overlays": [
      {
        "id": "temp_outside",
        "topic": "zigbee2mqtt/Температура на улице",
        "json_field": "temperature",      // пусто = raw payload string
        "format": "%+.1f°C",                // printf
        "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"
      }
    ]
  }

MqttBrokerCfg делятся между всеми overlays (one connect_async per item
но shared credentials). Добавление новых overlays = редактирование JSON +
restart cfc-grid (hot-reload через ZMQ — Phase 12).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:07:05 +01:00
gx 362871a264 Phase 11b: глобальный temperature overlay (правый-нижний угол)
User: "в предыдущих версиях у нас показывалась температура, можешь
сделать в нижнем правом углу оверлей не привязанный к сеткам?". Восстановили
прежнее поведение из vf-cuda-grid/controller (dynamic_overlays.py).

Новый класс cfc::TempMqttOverlay:
  - libmosquitto subscriber в отдельном thread'е, auto-reconnect 1→30s
  - json-c для parse JSON payload — extract поле .temperature (double)
  - Формат: "%+.1f°C" (например "+18.5°C")
  - Persistent FreeType text overlay (cfc_overlay_create_text), kept в
    Composer::overlays_[] backward-compat листе — рендерится поверх Layout
  - reposition_overlay() — пересчёт x/y после text_size() (right-bottom anchor)

C-shim (composer_c_api.cpp) для grid_record.c:
  - cfc_temp_overlay_start(composer, host, port, user, pw, topic, W, H)
  - singleton (один temp overlay на процесс, прода-композитору хватит)

CLI: --temp-topic="zigbee2mqtt/Температура на улице". MQTT credentials
переиспользуются из --mqtt-host/--mqtt-user/--mqtt-pass.

Compose override (localhost-infra):
  --temp-topic=zigbee2mqtt/Температура на улице

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 09:58:47 +01:00
gx 24d398526e Phase 11b: вернули asymmetric layouts с widget cells
User: "где сетки main+preview, сетки с виджетами?". Восстановили из
прежней истории:
  - tpl_3 (1+2+widget+widget): main 1440×810 + 2 преview 480×270 +
    widget temp_chart справа + widget ha_chat снизу
  - tpl_5 (1+4+widget): main + 4 preview right column + ha_chat снизу
  - tpl_6 (1+3+2+widget): main + 3 правых + 2 нижних + widget info
  - tpl_7 (1+3+3+widget): main + 3 правых + 3 нижних + widget ha_chat
  - tpl_8 (1+3+4): main + 3 правых + 4 в нижней строке (полное покрытие)
  - tpl_9 — 3×3 + widget полосы справа/снизу

Widget cells — placeholder (тёмно-серый Y=40 + LabelDecoration с именем
widget'а). Реальные виджеты (HA chat, температурный график) — Phase 12+.
Chёрные ячейки больше не будут — composer.maybe_relayout заполняет
свободные camera-cells остальными drawable из pool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 09:44:00 +01:00
gx e8392dd5ff Phase 11b: серые рамки 2px вокруг каждой cell
User: "ячейки не имеют границ, сделай их серым цветом". В Layout::apply()
для каждого Cell (CameraCell/BlankCell/WidgetCell) добавлен BorderDecoration:
  - thickness 2px
  - Y=180, U=128, V=128 (нейтральный серый в BT.709 limited)
  - alpha 220 (слегка полупрозрачный — видно контент за рамкой)

Decoration рисуется поверх content (после draw_content) — поверх любого
кадра/widget'а.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 09:30:11 +01:00
gx 858fe61b56 Phase 11b: hybrid PTZ — set_layout с timed motion freeze
User: "PTZ снова не переключает сетки". Причина: при motion-mode set_layout
игнорировался. Теперь: применяется + замораживает motion-mode на
manual_override_duration_ms_ (60s default). По истечении — auto-возврат
в motion-mode.

В Composer добавлено:
  - manual_override_until_ms_ (моноклоk монотонное время)
  - manual_override_duration_ms_ (default 60s)
  - set_layout: применяет template, ставит override timestamp
  - maybe_relayout: пока now < override → пропускаем (sustain manual layout),
    после → лог "expired, возврат в motion-mode" + force relayout

ONVIF server.py одновременно обновлён под актуальные template имена:
  - PTZ_PRESETS: tpl_1 / tpl_4 / tpl_9 / tpl_16 (вместо single/quad/...)
  - ContinuousMove zoom-in → tpl_1, zoom-out → tpl_16,
    pan/tilt → cycle через эти 4

Production smoke:
  GotoPreset tpl_4 → composer log "manual override 'tpl_4' до +60000ms" PASS.

Refs: #195.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 09:24:40 +01:00
gx 9d2a0b2bd7 Phase 11b: fill свободных camera-cells остальными drawable камерами
User: "смущает чёрная ячейка в сетке". Причина: asymmetric templates
имели widget cells (placeholder тёмно-серый Y=40) + при недостатке active
camera cells оставались BlankCell (чёрный).

Два изменения:

1. templates.json — оставили только 16:9 layouts (tpl_1/tpl_4/tpl_9/tpl_16).
   Все camera-cells, никаких widget-областей. Cells full 16:9 (cs==rs
   микроячейки), полностью покрывают output 1920×1080 без чёрных полос.
   Asymmetric layouts (main + satellites) удалены — вернуть в Phase 12
   когда widget'ы будут реальными (HA-chat, temperature graph).

2. composer::maybe_relayout — заполнить свободные camera-cells остальными
   drawable камерами из pool (по priority), если template имеет больше
   cells чем motion-active. Условие: cap > active.size().

Производство при 4 источниках в pool:
  - 1 motion → tpl_1 (1 cell full screen)
  - 2 motion → tpl_4 (2 motion + 2 not-active drawable = 4 cells заняты)
  - 4 motion → tpl_4 (все 4 motion)
  - При добавлении новых камер (до 16) — tpl_9 при 5..9, tpl_16 при 10..16

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 09:15:20 +01:00
gx 6e0273f4b4 Phase 11b F: extern "C" ABI shim + production deploy
В прод деплоен gx/cuframes-composer:0.11b-step1 — C++ ядро
работает через ABI shim, старые C-callers (grid_record.c, control.c,
frigate_mqtt.c) использует те же cfc_composer_* функции.

Что в этом коммите:
  - src/cpp/composer_c_api.cpp: extern "C" обёртки над cfc::Composer
    методами. Полный набор: _create/_destroy/_compose/_add_overlay/
    _find_overlay/_set_layout/_current_layout/_add_pool_source/
    _set_motion_mode/_get_motion_mode/_motion_pulse/_get_health.
  - src/cpp/layouts_c_api.cpp: extern "C" обёртки над template_loader
    для cfc_layout_find/_all/_load_file/_reload/_loaded_path/_to_pixels.
  - cpp/template_loader: global registry (current_templates / set_*
    / load_into_current) — единый источник истины. Composer и C ABI
    shim читают один и тот же mutex-защищённый vector<LayoutTemplate>.
    Hot-reload через ZMQ cfc_layout_load_file подхватывается composer'ом
    на следующем кадре без рестарта.
  - cpp/composer: pick_best_fit, set_layout, maybe_relayout читают
    current_templates() вместо локального snapshot.
  - cpp/composer: backward-compat overlay list (add_overlay/find_overlay)
    + manual cells support (для C API без motion-mode).
  - cpp/composer compose_frame: после Layout.render() рендерит overlays
    (CLI text/icon/border + Frigate detbox) поверх.
  - Удалены: src/composer.c (заменён composer_c_api.cpp + composer.cpp),
    src/layouts.c (заменён layouts_c_api.cpp + template_loader.cpp).
  - Оставлено как есть: src/overlay.c (PNG/text/border/detbox CLI overlays
    — реализация не меняется, доступ через cfc_overlay_*).
  - src/CMakeLists.txt: COMPOSER_SOURCES_C минус composer.c, layouts.c,
    COMPOSER_SOURCES_CPP плюс composer_c_api.cpp, layouts_c_api.cpp.

Production smoke (R9-88.23):
  [cfc/loader] /opt/templates.json: loaded 7 templates
  [cfc/composer] templates loaded: 7 (path='/opt/templates.json')
  [cfc/composer] pool+ cam-parking prio=100 / cam-gate_lpr prio=90 / ...
  [cfc/composer] motion_mode=1 ttl=45000ms pool=4
  [cfc/composer] grow → template='tpl_1' active=1
PASS.

Refs: #195 (Phase 11b C++ refactor).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 08:57:30 +01:00
gx beb8e1baa0 Phase 11b B-E: ООП-гипотеза проверена end-to-end
Что в этом коммите:

Decoration реализации:
  - cpp/label_decoration.hpp/.cpp — FreeType atlas + cugrid_blit_rgba_nv12.
    UTF-8 декодер, atlas в VRAM (RAII через CudaBuffer), rebuild при set_text.
  - cpp/border_decoration.hpp/.cpp — 4 cugrid_fill_nv12 (top/bottom/left/right).

Cell реализации:
  - cpp/camera_cell.hpp/.cpp — cfc_source_get_latest + cugrid_resize_nv12.
    Non-owning указатель на cfc_source_t (pool владеет).
  - cpp/widget_cell.hpp/.cpp — тёмный fill placeholder.
  - cpp/blank_cell.hpp/.cpp — BT.709 black fill.

Layout и Template:
  - cpp/template.hpp — LayoutTemplate { name, cells[], priority }.
    8×8 микро-сетка (kGridCols=kGridRows=8). to_pixels() переводит в Rect.
  - cpp/layout.hpp/.cpp — vector<unique_ptr<Cell>>, apply() создаёт
    CameraCell/WidgetCell/BlankCell + Decorations (Label с "{key} prio={N}").
  - cpp/template_loader.hpp/.cpp — JSON → vector<LayoutTemplate> через json-c.
    builtin_templates() = { tpl_1, tpl_4 } как fallback.

SourcePool:
  - cpp/source_pool.hpp/.cpp — owner cfc_source_t*, motion state атомарный,
    zone-filter в motion_pulse. Pool entries — non-copyable unique_ptr.

Composer:
  - cpp/composer.hpp/.cpp — owner SourcePool + templates + Layout + output.
    Алгоритмы: pick_best_fit (min nb_camera_cells >= need + priority tie-break),
    collect_active (drawable AND motion_within_TTL), asymmetric hysteresis
    (рост сразу через std::includes, сжатие — wait shrink_hysteresis_ms).
    Public C++ API: set_motion_mode / set_layout / load_templates / compose_frame.

ООП-гипотеза smoke:
  - examples/grid_record_cpp.cpp — минимальный smoke без NVENC. Init composer,
    compose_frame N раз, dump NV12 в файл. Проверяет что C++ модель
    компилируется, линкуется с C-кодом (source.c, nvenc.c остались на C через
    extern "C"), и реально рисует кадр.

Производительность сохранена:
  - Один output буфер VMM, передаётся как NV12Ref (read-write reference) во все
    cells/decorations — НИКАКИХ memcpy на cells boundary.
  - Virtual call overhead: 1 indirect call per cell per frame. Negligible.
  - Heap allocations только при apply_template (раз в N секунд при relayout).

Build:
  - CMakeLists.txt: CXX language, C++17.
  - src/CMakeLists.txt: COMPOSER_SOURCES_CPP добавлен в lib.
  - examples/CMakeLists.txt: grid_record_cpp.

Smoke test run jammy:
  [cfc/loader] docker/templates.json: loaded 7 templates
  [smoke] composer 1920x1080 templates=7 sources=0 motion=0
  [smoke] wrote 3317760 bytes (Y=2211840 UV=1105920) to /out/blank.nv12
  Build PASS, init PASS, compose PASS, dump PASS.

Что НЕ сделано:
  - extern "C" ABI shim для control.c / grid_record.c (старый C-композитор
    всё ещё единственный для prod stack).
  - Удаление старых composer.c / overlay.c / layouts.c.
  - Live deploy в прод (Step 1-3 функциональность).
  - JSON ZMQ hot-reload (был в Step 3 C-version, восстановить в C++).

Refs: #195 (Phase 11b C++ refactor).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 21:43:18 +01:00
gx f1c79eabde Phase 11b A: CMake C++17 + базовые headers
Branch phase11b-cpp — refactor композитора на ООП.

Что сделано в этом коммите:
  - CMakeLists.txt: CMAKE_CXX_STANDARD 17, language=CXX
  - include/cuframes_composer/cpp/cuda_raii.hpp: CudaBuffer + CudaStream
    как RAII обёртки (cuMemAlloc/cuMemFree, cuStreamCreate/Destroy).
    Non-copyable, movable. Zero-copy: handle CUdeviceptr передаётся
    идентично C-коду.
  - cpp/types.hpp: Rect (pixel coords) + NV12Ref (общий read-write
    референс на Y/UV plane'ы output буфера — composer + cells + decorations
    делят его без копий).
  - cpp/decoration.hpp: абстрактный Decoration с draw(stream, dst, parent_rect).
  - cpp/cell.hpp: абстрактный Cell с draw() = draw_content() +
    iterate decorations. Композиция через add_decoration().

Что НЕ сделано (следующие коммиты):
  - CameraCell, WidgetCell, BlankCell (cell-content реализации)
  - LabelDecoration, BorderDecoration (с FreeType/cugrid)
  - Layout (контейнер cells + apply_template)
  - Composer класс (owner SourcePool + Layout + OutputSurface)
  - extern "C" ABI shim для совместимости с control.c, grid_record.c
  - Удаление старых composer.c / overlay.c / layouts.c
  - Восстановление функционала JSON templates + auto-labels

Производительность: virtual call overhead 1 indirect call per cell per
frame (negligible), никаких heap allocations в hot path, CUDA pipeline
1:1 идентичен C-версии.

Refs: #195 (Phase 11b C++ refactor)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 21:27:44 +01:00
47 changed files with 6479 additions and 1101 deletions
+7 -1
View File
@@ -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-сценарии:
+84 -34
View File
@@ -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 11bproduction.** Развёрнут на 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+
+22
View File
@@ -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
View File
@@ -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}
]
}
]
}
+612
View File
@@ -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.
+402
View File
@@ -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
View File
@@ -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
+615
View File
@@ -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 с описанием
ключевых изменений.
+404
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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);
+177
View File
@@ -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 */
+64
View File
@@ -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 */
+152
View File
@@ -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 */
+134
View File
@@ -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 */
+62
View File
@@ -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 */
+48
View File
@@ -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 */
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+18
View File
@@ -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
+50
View File
@@ -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
+33
View File
@@ -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
+392
View File
@@ -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
+197
View File
@@ -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"
+168
View File
@@ -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
+94
View File
@@ -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
+136
View File
@@ -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"
+317
View File
@@ -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
+45
View File
@@ -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"
+110
View File
@@ -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
+172
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;