diff --git a/CMakeLists.txt b/CMakeLists.txt index 5101ef8..2f11bd8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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-сценарии: diff --git a/docker/mqtt_overlays.json b/docker/mqtt_overlays.json new file mode 100644 index 0000000..2eeb29f --- /dev/null +++ b/docker/mqtt_overlays.json @@ -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" + } + ] +} diff --git a/docker/templates.json b/docker/templates.json index 26bd0b8..e643eed 100644 --- a/docker/templates.json +++ b/docker/templates.json @@ -2,19 +2,19 @@ "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 + 2 превью 480×270 + widget справа-низ + 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}, @@ -25,7 +25,7 @@ }, { "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 +35,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 +47,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 +60,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 +74,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 +85,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} + ] } ] } diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 8591b90..c8dda3e 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -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) diff --git a/examples/grid_record.c b/examples/grid_record.c index 8a792b1..850d2f9 100644 --- a/examples/grid_record.c +++ b/examples/grid_record.c @@ -127,6 +127,7 @@ int main(int argc, char **argv) const char *frigate_mqtt_host = NULL; int frigate_mqtt_port = 1883; const char *frigate_topic = "frigate/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 */ @@ -179,11 +180,12 @@ int main(int argc, char **argv) {"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:D:L:S:mk:z:x:", opts, NULL)) != -1) { switch (c) { case 'o': out_path = optarg; break; case 'c': @@ -241,6 +243,7 @@ int main(int argc, char **argv) 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 +449,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 diff --git a/examples/grid_record_cpp.cpp b/examples/grid_record_cpp.cpp new file mode 100644 index 0000000..b6873d9 --- /dev/null +++ b/examples/grid_record_cpp.cpp @@ -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 +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 zones; +}; + +std::vector split_colon(const std::string& s) +{ + std::vector 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 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 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(last.pitch_y) * height; + std::size_t uv_size = static_cast(last.pitch_uv) * (height / 2); + std::vector 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; +} diff --git a/include/cuframes_composer/cpp/blank_cell.hpp b/include/cuframes_composer/cpp/blank_cell.hpp new file mode 100644 index 0000000..febd78f --- /dev/null +++ b/include/cuframes_composer/cpp/blank_cell.hpp @@ -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 */ diff --git a/include/cuframes_composer/cpp/border_decoration.hpp b/include/cuframes_composer/cpp/border_decoration.hpp new file mode 100644 index 0000000..f790c51 --- /dev/null +++ b/include/cuframes_composer/cpp/border_decoration.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 */ diff --git a/include/cuframes_composer/cpp/camera_cell.hpp b/include/cuframes_composer/cpp/camera_cell.hpp new file mode 100644 index 0000000..42f18b6 --- /dev/null +++ b/include/cuframes_composer/cpp/camera_cell.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 + +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 */ diff --git a/include/cuframes_composer/cpp/cell.hpp b/include/cuframes_composer/cpp/cell.hpp new file mode 100644 index 0000000..896dd00 --- /dev/null +++ b/include/cuframes_composer/cpp/cell.hpp @@ -0,0 +1,64 @@ +/* Cell — базовый абстрактный класс ячейки композитора (Phase 11b). + * + * Cell — это прямоугольная область output frame, рисуемая в свою geom_. + * Реализации (CameraCell, WidgetCell, BlankCell) определяют content-рендер; + * декорации (Label, Border) добавляются композицией через add_decoration(). + * + * Lifecycle: + * 1. Layout::apply_template() создаёт нужные Cell-подклассы. + * 2. На каждом compose: cell.draw(stream, dst) рисует свой контент + * + все decorations. + * 3. Layout уничтожает cells при apply нового template'а. + * + * Лицензия: LGPL-2.1+ + */ + +#ifndef CUFRAMES_COMPOSER_CPP_CELL_HPP +#define CUFRAMES_COMPOSER_CPP_CELL_HPP + +#include "decoration.hpp" +#include "types.hpp" + +#include +#include + +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 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> decorations_; +}; + +} // namespace cfc + +#endif /* CUFRAMES_COMPOSER_CPP_CELL_HPP */ diff --git a/include/cuframes_composer/cpp/composer.hpp b/include/cuframes_composer/cpp/composer.hpp new file mode 100644 index 0000000..f376b76 --- /dev/null +++ b/include/cuframes_composer/cpp/composer.hpp @@ -0,0 +1,152 @@ +/* Composer — оркестратор Phase 11b. + * + * Owns: + * - SourcePool (cuframes-источники + motion state) + * - vector (loaded from JSON или builtins) + * - Layout (текущее состояние cells) + * - OutputSurface (CudaBuffer для NV12 output) + * + * Compose loop (по кадру): + * 1. select_template_and_active() → (LayoutTemplate*, vector) + * по правилам: 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 +#include +#include +#include +#include + +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(templates_.size()); + } + const std::vector& 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>& cells); + + /* Один кадр: relayout (если нужно) + clear + render. + * Возвращает NV12Ref на output (ptr действителен до следующего compose). */ + NV12Ref compose_frame(); + +private: + /* Selection + hysteresis. */ + const LayoutTemplate* pick_best_fit(int need) const; + std::vector collect_active() const; + void maybe_relayout(); + static std::string build_signature(const std::string& tpl_name, + const std::vector& active); + + ComposerConfig cfg_; + SourcePool pool_; + std::vector 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 overlays_; + + /* Manual cells — alternative режим без motion-mode (grid_record --cell). */ + std::vector> manual_cells_; + bool manual_applied_ = false; +}; + +} // namespace cfc + +#endif /* CUFRAMES_COMPOSER_CPP_COMPOSER_HPP */ diff --git a/include/cuframes_composer/cpp/cuda_raii.hpp b/include/cuframes_composer/cpp/cuda_raii.hpp new file mode 100644 index 0000000..1644ff4 --- /dev/null +++ b/include/cuframes_composer/cpp/cuda_raii.hpp @@ -0,0 +1,134 @@ +/* RAII обёртки над CUDA Driver/Runtime ресурсами (Phase 11b). + * + * Передача handle'ов между объектами по-прежнему zero-copy (CUdeviceptr — + * это unsigned long long; обмен идентичен plain C-коду). Эти обёртки только + * автоматизируют lifetime — без них приходилось бы вручную помнить про + * cuMemFree и закрывать stream'ы в путях ошибок. + * + * NB: классы non-copyable (чтобы не вызвать двойной cuMemFree), но movable. + * + * Лицензия: LGPL-2.1+ + */ + +#ifndef CUFRAMES_COMPOSER_CPP_CUDA_RAII_HPP +#define CUFRAMES_COMPOSER_CPP_CUDA_RAII_HPP + +#include +#include +#include + +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(stream_); } + +private: + cudaStream_t stream_ = nullptr; + bool owned_ = false; +}; + +} // namespace cfc + +#endif /* CUFRAMES_COMPOSER_CPP_CUDA_RAII_HPP */ diff --git a/include/cuframes_composer/cpp/decoration.hpp b/include/cuframes_composer/cpp/decoration.hpp new file mode 100644 index 0000000..4ae008c --- /dev/null +++ b/include/cuframes_composer/cpp/decoration.hpp @@ -0,0 +1,33 @@ +/* Decoration — украшение поверх cell (Phase 11b). + * + * Cell держит vector> и вызывает 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 */ diff --git a/include/cuframes_composer/cpp/label_decoration.hpp b/include/cuframes_composer/cpp/label_decoration.hpp new file mode 100644 index 0000000..c595db8 --- /dev/null +++ b/include/cuframes_composer/cpp/label_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 +#include FT_FREETYPE_H + +#include +#include + +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 */ diff --git a/include/cuframes_composer/cpp/layout.hpp b/include/cuframes_composer/cpp/layout.hpp new file mode 100644 index 0000000..4148f2d --- /dev/null +++ b/include/cuframes_composer/cpp/layout.hpp @@ -0,0 +1,62 @@ +/* Layout — контейнер cells и оркестратор apply_template (Phase 11b). + * + * Layout::apply() принимает LayoutTemplate + список активных pool-entries + * (sorted by priority DESC) + output W×H. Создаёт нужные Cell-подклассы: + * + * CameraCell для каждой template-cell с role=CAMERA — берёт source + * по индексу из active list (active[0]=order 0, active[1]=order 1, ...) + * Если active меньше чем camera-cells — лишние cells = BlankCell. + * WidgetCell для template-cell с role=WIDGET — placeholder. + * + * Decorations добавляются здесь же: + * LabelDecoration "{key} prio={N}" в каждый CameraCell. + * LabelDecoration с именем widget'а в каждый WidgetCell. + * (Border, Badge — Phase 12+) + * + * Layout::render(stream, dst) — итеративно вызывает cell->draw(). + * + * Лицензия: LGPL-2.1+ + */ + +#ifndef CUFRAMES_COMPOSER_CPP_LAYOUT_HPP +#define CUFRAMES_COMPOSER_CPP_LAYOUT_HPP + +#include "cell.hpp" +#include "source_pool.hpp" +#include "template.hpp" + +#include +#include +#include + +namespace cfc { + +class Layout { +public: + Layout() = default; + + /* Применить template — пересоздаёт cells + decorations. + * active_sorted — список pool-entries, уже отсортированный priority DESC. */ + void apply(const LayoutTemplate& tpl, + const std::vector& 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(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> cells_; + std::string current_name_; +}; + +} // namespace cfc + +#endif /* CUFRAMES_COMPOSER_CPP_LAYOUT_HPP */ diff --git a/include/cuframes_composer/cpp/mqtt_overlay.hpp b/include/cuframes_composer/cpp/mqtt_overlay.hpp new file mode 100644 index 0000000..b90df9d --- /dev/null +++ b/include/cuframes_composer/cpp/mqtt_overlay.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, поднимает + * 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 +#include +#include +#include + +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 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 overlay_handles() const; + + void clear(); + int size() const { return static_cast(items_.size()); } + +private: + MqttBrokerCfg broker_; + std::vector> items_; +}; + +} // namespace cfc + +#endif /* CUFRAMES_COMPOSER_CPP_MQTT_OVERLAY_HPP */ diff --git a/include/cuframes_composer/cpp/source_pool.hpp b/include/cuframes_composer/cpp/source_pool.hpp new file mode 100644 index 0000000..44daa7c --- /dev/null +++ b/include/cuframes_composer/cpp/source_pool.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 +#include +#include +#include +#include + +namespace cfc { + +struct PoolEntry { + std::string cuframes_key; + std::string frigate_camera; + int priority = 0; + cfc_source_t* source = nullptr; + std::atomic last_motion_ms{0}; + std::vector 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& zones, + const SubscribeOpts& opts); + + int size() const { return static_cast(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& current_zones); + + /* Итерация (для best-fit selection и health). */ + template + void for_each(F&& fn) { + for (auto& e : entries_) fn(*e); + } + +private: + std::vector> entries_; + std::mutex mu_; +}; + +} // namespace cfc + +#endif /* CUFRAMES_COMPOSER_CPP_SOURCE_POOL_HPP */ diff --git a/include/cuframes_composer/cpp/template.hpp b/include/cuframes_composer/cpp/template.hpp new file mode 100644 index 0000000..4f91cd1 --- /dev/null +++ b/include/cuframes_composer/cpp/template.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 +#include + +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 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 */ diff --git a/include/cuframes_composer/cpp/template_loader.hpp b/include/cuframes_composer/cpp/template_loader.hpp new file mode 100644 index 0000000..90c42b1 --- /dev/null +++ b/include/cuframes_composer/cpp/template_loader.hpp @@ -0,0 +1,35 @@ +/* Template loader — JSON → vector (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 +#include + +namespace cfc { + +/* Загрузить из файла. Возвращает количество загруженных templates, либо + * отрицательное число при ошибке (-1=parse, -2=schema, -3=open). */ +int load_templates_from_file(const std::string& path, + std::vector& out); + +/* Встроенный набор fallback templates (Phase 11b base — single, quad). */ +std::vector 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& current_templates(); +void set_current_templates(std::vector new_templates); +int load_into_current(const std::string& path); + +} // namespace cfc + +#endif /* CUFRAMES_COMPOSER_CPP_TEMPLATE_LOADER_HPP */ diff --git a/include/cuframes_composer/cpp/types.hpp b/include/cuframes_composer/cpp/types.hpp new file mode 100644 index 0000000..371a8fe --- /dev/null +++ b/include/cuframes_composer/cpp/types.hpp @@ -0,0 +1,48 @@ +/* Базовые типы C++-модели композитора (Phase 11b). + * + * Rect — pixel-координаты в output frame buffer'е (1920×1080 default). + * NV12Surface — wrapper над VMM-buffer'ом с pitch_y/pitch_uv для совместного + * использования compose и encoder'ом. По сути reference на CUdeviceptr — + * никаких копий не делается, ownership держит OutputSurface. + * + * Лицензия: LGPL-2.1+ + */ + +#ifndef CUFRAMES_COMPOSER_CPP_TYPES_HPP +#define CUFRAMES_COMPOSER_CPP_TYPES_HPP + +#include + +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 */ diff --git a/include/cuframes_composer/cpp/widget_cell.hpp b/include/cuframes_composer/cpp/widget_cell.hpp new file mode 100644 index 0000000..89c18f9 --- /dev/null +++ b/include/cuframes_composer/cpp/widget_cell.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 + +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 */ diff --git a/include/cuframes_composer/overlay.h b/include/cuframes_composer/overlay.h index 5221bf2..a67554d 100644 --- a/include/cuframes_composer/overlay.h +++ b/include/cuframes_composer/overlay.h @@ -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, рендерит строку @@ -168,6 +176,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 (объект вне зон) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d6d79fe..26b6fe6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 $<$:-Wall> $<$:-Wextra> $<$:-Wpedantic> + $<$:-Wall> + $<$:-Wextra> $<$,$>:-O0> $<$,$>:-g3> $<$,$>:-O2> $<$,$>:-g> + $<$,$>:-O2> + $<$,$>:-g> ) target_link_libraries(${target} PUBLIC diff --git a/src/composer.c b/src/composer.c deleted file mode 100644 index 618fed4..0000000 --- a/src/composer.c +++ /dev/null @@ -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 -#include -#include -#include -#include -#include -#include -#include - -#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; -} diff --git a/src/cpp/blank_cell.cpp b/src/cpp/blank_cell.cpp new file mode 100644 index 0000000..1f0755f --- /dev/null +++ b/src/cpp/blank_cell.cpp @@ -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 diff --git a/src/cpp/border_decoration.cpp b/src/cpp/border_decoration.cpp new file mode 100644 index 0000000..c7d7f6a --- /dev/null +++ b/src/cpp/border_decoration.cpp @@ -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 diff --git a/src/cpp/camera_cell.cpp b/src/cpp/camera_cell.cpp new file mode 100644 index 0000000..fe1b194 --- /dev/null +++ b/src/cpp/camera_cell.cpp @@ -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(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 diff --git a/src/cpp/composer.cpp b/src/cpp/composer.cpp new file mode 100644 index 0000000..5743994 --- /dev/null +++ b/src/cpp/composer.cpp @@ -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 +#include +#include +#include + +namespace cfc { + +static std::int64_t now_ms_mono() +{ + using namespace std::chrono; + return duration_cast(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(pitch_y_) * cfg_.height + + static_cast(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(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>& 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 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 Composer::collect_active() const +{ + std::vector active; + std::int64_t now = now_ms_mono(); + const_cast(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(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& active) +{ + std::string sig = tpl_name + "|"; + std::vector 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(active.size())); + if (!tpl) return; + + /* Cap по template'у */ + int cap = tpl->nb_camera_cells(); + if (static_cast(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(active.size()) < cap) { + std::vector already(active.begin(), active.end()); + std::vector extras; + const_cast(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(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 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 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(i); + t.cells.push_back(std::move(c)); + } + /* Build active list из pool entries по cuframes_key. */ + std::vector 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(pitch_y_) * cfg_.height; + cudaMemsetAsync(reinterpret_cast(y), cfg_.bg_y, + static_cast(pitch_y_) * cfg_.height, stream_); + cfc_cugrid_fill_nv12(reinterpret_cast(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(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(stream_), + y, pitch_y_, uv, pitch_uv_, + cfg_.width, cfg_.height); + } + return dst; +} + +} // namespace cfc diff --git a/src/cpp/composer_c_api.cpp b/src/cpp/composer_c_api.cpp new file mode 100644 index 0000000..49d02f7 --- /dev/null +++ b/src/cpp/composer_c_api.cpp @@ -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 +#include +#include +#include +#include + +namespace { + +inline cfc::Composer* as_cpp(cfc_composer_t* h) +{ + return reinterpret_cast(h); +} +inline cfc_composer_t* as_c(cfc::Composer* c) +{ + return reinterpret_cast(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> 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 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 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" diff --git a/src/cpp/label_decoration.cpp b/src/cpp/label_decoration.cpp new file mode 100644 index 0000000..c00e21c --- /dev/null +++ b/src/cpp/label_decoration.cpp @@ -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 +#include +#include +#include + +namespace cfc { + +/* UTF-8 декодер — возвращает true если ещё есть данные, advance'ит p. */ +static bool utf8_next(const char*& p, std::uint32_t& cp) +{ + auto s = reinterpret_cast(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(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(gy); + if (dy < 0 || dy >= h) continue; + for (unsigned gx = 0; gx < bm->width; gx++) { + int dx = pen_x + bx + static_cast(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(dy) * w + dx) * 4; + int ca = dst[3]; + int new_a = a + (ca * (255 - a)) / 255; + if (new_a > 0) { + dst[0] = static_cast((style_.r * a + dst[0] * ca * (255 - a) / 255) / new_a); + dst[1] = static_cast((style_.g * a + dst[1] * ca * (255 - a) / 255) / new_a); + dst[2] = static_cast((style_.b * a + dst[2] * ca * (255 - a) / 255) / new_a); + dst[3] = static_cast(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(std::malloc(static_cast(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(w) * h * 4); + if (cr != CUDA_SUCCESS) { std::free(cpu); return false; } + cr = cuMemcpyHtoD(atlas_, cpu, static_cast(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 diff --git a/src/cpp/layout.cpp b/src/cpp/layout.cpp new file mode 100644 index 0000000..046e847 --- /dev/null +++ b/src/cpp/layout.cpp @@ -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 +#include + +namespace cfc { + +void Layout::apply(const LayoutTemplate& tpl, + const std::vector& 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 camera_templates; + std::vector 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(r, active_sorted[i]->source, + active_sorted[i]->cuframes_key); + cell->add_decoration(std::make_unique(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(label_buf, LabelStyle{})); + cells_.push_back(std::move(cell)); + } else { + /* Нет active под этот слот → blank. */ + auto cell = std::make_unique(r); + cell->add_decoration(std::make_unique(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(r, wt->widget); + cell->add_decoration(std::make_unique(border_style)); + if (!wt->widget.empty()) { + cell->add_decoration(std::make_unique(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(c.get()); + if (cc && cc->source_key() == source_key) { + return &cc->geometry(); + } + } + return nullptr; +} + +} // namespace cfc diff --git a/src/cpp/layouts_c_api.cpp b/src/cpp/layouts_c_api.cpp new file mode 100644 index 0000000..e6008ec --- /dev/null +++ b/src/cpp/layouts_c_api.cpp @@ -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 +#include +#include +#include +#include + +namespace { + +static std::mutex g_mu; +static std::vector g_c_cache; +static std::string g_loaded_path; +static std::vector 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(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 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 lk(g_mu); + ensure_loaded_locked(); + if (out_count) *out_count = static_cast(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 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 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 lk(g_mu); + return g_loaded_path.empty() ? nullptr : g_loaded_path.c_str(); +} + +} // extern "C" diff --git a/src/cpp/mqtt_overlay.cpp b/src/cpp/mqtt_overlay.cpp new file mode 100644 index 0000000..6784720 --- /dev/null +++ b/src/cpp/mqtt_overlay.cpp @@ -0,0 +1,317 @@ +/* MqttOverlay — реализация (Phase 11b). */ + +#include "../../include/cuframes_composer/cpp/mqtt_overlay.hpp" + +#include +#include + +#include +#include +#include +#include + +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(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(user); + if (!msg || !msg->payload || msg->payloadlen <= 0) return; + self->handle_payload(static_cast(msg->payload), + static_cast(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(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(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(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(items_.size()); +} + +std::vector MqttOverlayManager::overlay_handles() const +{ + std::vector v; + v.reserve(items_.size()); + for (auto& i : items_) v.push_back(i->overlay()); + return v; +} + +void MqttOverlayManager::clear() +{ + items_.clear(); +} + +} // namespace cfc diff --git a/src/cpp/mqtt_overlay_c_api.cpp b/src/cpp/mqtt_overlay_c_api.cpp new file mode 100644 index 0000000..587ca2f --- /dev/null +++ b/src/cpp/mqtt_overlay_c_api.cpp @@ -0,0 +1,45 @@ +/* C wrapper для MqttOverlayManager (Phase 11b). */ + +#include "../../include/cuframes_composer/composer.h" +#include "../../include/cuframes_composer/cpp/mqtt_overlay.hpp" + +#include + +namespace { +std::unique_ptr 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(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" diff --git a/src/cpp/source_pool.cpp b/src/cpp/source_pool.cpp new file mode 100644 index 0000000..8169e1e --- /dev/null +++ b/src/cpp/source_pool.cpp @@ -0,0 +1,110 @@ +/* SourcePool — реализация (Phase 11b). */ + +#include "../../include/cuframes_composer/cpp/source_pool.hpp" + +#include +#include +#include + +namespace cfc { + +static std::int64_t now_ms_mono() +{ + using namespace std::chrono; + return duration_cast(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& zones, + const SubscribeOpts& opts) +{ + std::lock_guard 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(); + 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(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& current_zones) +{ + std::lock_guard 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 diff --git a/src/cpp/template_loader.cpp b/src/cpp/template_loader.cpp new file mode 100644 index 0000000..34ece79 --- /dev/null +++ b/src/cpp/template_loader.cpp @@ -0,0 +1,172 @@ +/* Template loader — реализация (Phase 11b). */ + +#include "../../include/cuframes_composer/cpp/template_loader.hpp" + +#include + +#include +#include +#include +#include + +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(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& 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(json_object_array_length(jtpls)); + std::vector 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(out.size()); +} + +std::vector builtin_templates() +{ + std::vector 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 g_registry; + +void ensure_registry_locked() +{ + if (g_registry.empty()) g_registry = builtin_templates(); +} +} // namespace + +const std::vector& current_templates() +{ + std::lock_guard lk(g_reg_mu); + ensure_registry_locked(); + return g_registry; +} + +void set_current_templates(std::vector new_templates) +{ + if (new_templates.empty()) return; + std::lock_guard lk(g_reg_mu); + g_registry = std::move(new_templates); +} + +int load_into_current(const std::string& path) +{ + std::vector v; + int r = load_templates_from_file(path, v); + if (r > 0) { + set_current_templates(std::move(v)); + } + return r; +} + +} // namespace cfc diff --git a/src/cpp/widget_cell.cpp b/src/cpp/widget_cell.cpp new file mode 100644 index 0000000..282fcf2 --- /dev/null +++ b/src/cpp/widget_cell.cpp @@ -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 diff --git a/src/layouts.c b/src/layouts.c deleted file mode 100644 index 9b364c2..0000000 --- a/src/layouts.c +++ /dev/null @@ -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 -#include -#include -#include -#include -#include -#include - -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; -} diff --git a/src/overlay.c b/src/overlay.c index 2127220..0a31771 100644 --- a/src/overlay.c +++ b/src/overlay.c @@ -537,6 +537,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 +568,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, @@ -620,6 +647,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)