From f8e27b9e855e92b6e658e7bb64f00341daa6f298 Mon Sep 17 00:00:00 2001 From: Evgeny Demchenko Date: Wed, 3 Jun 2026 21:24:23 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2010/11=20WIP=20=E2=80=94=20pool=20+=20mo?= =?UTF-8?q?tion-mode=20+=208=C3=978=20templates=20(rolled=20back)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Объединённое состояние работ: - Phase 10: source pool, motion-driven layout, Frigate motion_pulse, zone-filter в pool, --source / --motion-mode CLI - Phase 11 Steps 1-3c: 8×8 микро-сетка, JSON templates с asymmetric layouts (tpl_1/3/4/5/6/7/8), reload через ZMQ, auto-labels per camera В прод отдеплоено и откачено: используем gx/cuframes-composer:0.10 как baseline. Phase 11 продолжится в branch phase11b-cpp как C++ refactor с ООП-моделью Cell/Layout/Decoration. Co-Authored-By: Claude Opus 4.7 --- docker/Dockerfile | 3 + docker/templates.json | 90 +++++ examples/grid_record.c | 85 ++++- include/cuframes_composer/composer.h | 49 +++ include/cuframes_composer/frigate_mqtt.h | 5 + include/cuframes_composer/layouts.h | 96 ++++- src/composer.c | 454 +++++++++++++++++++++-- src/control.c | 98 ++++- src/frigate_mqtt.c | 40 +- src/layouts.c | 329 ++++++++++++---- 10 files changed, 1097 insertions(+), 152 deletions(-) create mode 100644 docker/templates.json diff --git a/docker/Dockerfile b/docker/Dockerfile index 389aa99..9d4477e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -72,6 +72,9 @@ COPY --from=builder /src/build/third_party/cuframes/libcuframes/libcuframes.so.0 COPY --from=builder /src/build/src/libcuframes_composer.so.0.1.0 \ /usr/lib/x86_64-linux-gnu/libcuframes_composer.so.0 +# Phase 11 — layout templates.json. Editable in-container или mount override. +COPY docker/templates.json /etc/cfc-grid/templates.json + # NVIDIA_DRIVER_CAPABILITIES должен включать 'video' для NVENC через libnvidia-encode. ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,video diff --git a/docker/templates.json b/docker/templates.json new file mode 100644 index 0000000..26bd0b8 --- /dev/null +++ b/docker/templates.json @@ -0,0 +1,90 @@ +{ + "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.", + + "templates": [ + { + "name": "tpl_1", + "_desc": "1 камера во весь экран.", + "cells": [ + {"col": 0, "row": 0, "cs": 8, "rs": 8, "role": "camera", "order": 0} + ] + }, + { + "name": "tpl_3", + "_desc": "Главная 1440×810 слева + 2 превью 480×270 справа стопкой, остаток — виджеты.", + "cells": [ + {"col": 0, "row": 0, "cs": 6, "rs": 6, "role": "camera", "order": 0}, + {"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1}, + {"col": 6, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 2}, + {"col": 6, "row": 4, "cs": 2, "rs": 4, "role": "widget", "widget": "temp_chart"}, + {"col": 0, "row": 6, "cs": 6, "rs": 2, "role": "widget", "widget": "ha_chat"} + ] + }, + { + "name": "tpl_4", + "_desc": "Quad 2×2: 4 камеры 960×540. order=0 — top-left (главная).", + "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}, + {"col": 0, "row": 4, "cs": 4, "rs": 4, "role": "camera", "order": 2}, + {"col": 4, "row": 4, "cs": 4, "rs": 4, "role": "camera", "order": 3} + ] + }, + { + "name": "tpl_5", + "_desc": "1 главная + 4 превью справа стопкой, нижняя полоса — виджет.", + "cells": [ + {"col": 0, "row": 0, "cs": 6, "rs": 6, "role": "camera", "order": 0}, + {"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1}, + {"col": 6, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 2}, + {"col": 6, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 3}, + {"col": 6, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 4}, + {"col": 0, "row": 6, "cs": 6, "rs": 2, "role": "widget", "widget": "ha_chat"} + ] + }, + { + "name": "tpl_6", + "_desc": "1 главная + 3 правые + 2 нижние, остаток — виджет.", + "cells": [ + {"col": 0, "row": 0, "cs": 6, "rs": 6, "role": "camera", "order": 0}, + {"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1}, + {"col": 6, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 2}, + {"col": 6, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 3}, + {"col": 0, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 4}, + {"col": 2, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 5}, + {"col": 4, "row": 6, "cs": 4, "rs": 2, "role": "widget", "widget": "info"} + ] + }, + { + "name": "tpl_7", + "_desc": "1 главная + 3 правые + 3 нижние, угол — виджет.", + "cells": [ + {"col": 0, "row": 0, "cs": 6, "rs": 6, "role": "camera", "order": 0}, + {"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1}, + {"col": 6, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 2}, + {"col": 6, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 3}, + {"col": 0, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 4}, + {"col": 2, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 5}, + {"col": 4, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 6}, + {"col": 6, "row": 6, "cs": 2, "rs": 2, "role": "widget", "widget": "ha_chat"} + ] + }, + { + "name": "tpl_8", + "_desc": "1+3+4 — главная + 3 правые + полная нижняя строка.", + "cells": [ + {"col": 0, "row": 0, "cs": 6, "rs": 6, "role": "camera", "order": 0}, + {"col": 6, "row": 0, "cs": 2, "rs": 2, "role": "camera", "order": 1}, + {"col": 6, "row": 2, "cs": 2, "rs": 2, "role": "camera", "order": 2}, + {"col": 6, "row": 4, "cs": 2, "rs": 2, "role": "camera", "order": 3}, + {"col": 0, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 4}, + {"col": 2, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 5}, + {"col": 4, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 6}, + {"col": 6, "row": 6, "cs": 2, "rs": 2, "role": "camera", "order": 7} + ] + } + ] +} diff --git a/examples/grid_record.c b/examples/grid_record.c index bd95908..8a792b1 100644 --- a/examples/grid_record.c +++ b/examples/grid_record.c @@ -128,6 +128,12 @@ int main(int argc, char **argv) int frigate_mqtt_port = 1883; const char *frigate_topic = "frigate/events"; const char *initial_layout = NULL; /* --layout NAME → set_layout после init */ + int motion_mode = 0; /* --motion-mode */ + int motion_ttl = 45000; /* --motion-ttl ms */ + /* --source cuframes_key,frigate=camera_name,priority=N[,zones=z1:z2:...] */ + typedef struct { char key[64], frigate[48], zones[128]; int priority; } source_spec_t; + source_spec_t sources[32] = { 0 }; + int num_sources = 0; /* --detection-cell key,camera,dx,dy,dw,dh,detect_w,detect_h[,zone1:zone2:...] * key — символьное имя для логов (например "parking") * camera — Frigate camera_key для MQTT match'а ("parking_overview") @@ -169,10 +175,15 @@ int main(int argc, char **argv) {"frigate-topic", required_argument, 0, 'T'}, {"detection-cell", required_argument, 0, 'D'}, {"layout", required_argument, 0, 'L'}, /* named layout (quad, single, ...) */ + {"source", required_argument, 0, 'S'}, /* pool source: key,frigate=...,priority=N */ + {"motion-mode", no_argument, 0, 'm'}, /* enable motion-driven auto layout */ + {"motion-ttl", required_argument, 0, 'k'}, /* TTL ms (default 45000) */ + {"templates", required_argument, 0, 'z'}, /* path to templates.json */ {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:", 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:", opts, NULL)) != -1) { switch (c) { case 'o': out_path = optarg; break; case 'c': @@ -227,6 +238,30 @@ int main(int argc, char **argv) } case 'T': frigate_topic = optarg; break; case 'L': initial_layout = optarg; break; + case 'm': motion_mode = 1; break; + case 'k': motion_ttl = atoi(optarg); break; + case 'z': templates_path = optarg; break; + case 'S': { + if (num_sources >= 32) { + fprintf(stderr, "max 32 sources\n"); return 1; + } + /* Формат: key[,frigate=name][,priority=N]. */ + char buf[256]; strncpy(buf, optarg, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + char *tok = strtok(buf, ","); + if (!tok) { fprintf(stderr, "bad --source\n"); return 1; } + strncpy(sources[num_sources].key, tok, sizeof(sources[num_sources].key) - 1); + while ((tok = strtok(NULL, ",")) != NULL) { + if (!strncmp(tok, "frigate=", 8)) { + strncpy(sources[num_sources].frigate, tok + 8, + sizeof(sources[num_sources].frigate) - 1); + } else if (!strncmp(tok, "priority=", 9)) { + sources[num_sources].priority = atoi(tok + 9); + } + } + num_sources++; + break; + } case 'D': { if (num_detcells >= MAX_CELLS) { fprintf(stderr, "max %d detcells\n", MAX_CELLS); return 1; } char buf[512]; strncpy(buf, optarg, sizeof(buf) - 1); buf[sizeof(buf)-1] = '\0'; @@ -340,18 +375,39 @@ int main(int argc, char **argv) default: return 1; } } - if (!out_path || num_cells == 0) { + if (!out_path || (num_cells == 0 && num_sources == 0)) { fprintf(stderr, "Использование: %s --out --cell key,x,y,w,h [--cell ...]\n" + " ИЛИ: %s --out --motion-mode --source ... [--source ...]\n" " [--width 3840] [--height 2160] [--fps 25]\n" - " [--bitrate 10000] [--seconds N]\n", - argv[0]); + " [--bitrate 10000] [--seconds N]\n" + " --source cuframes_key[,frigate=name][,priority=N]\n", + argv[0], argv[0]); return 1; } + /* Motion-mode + только --source: создаём placeholder cell (motion_relayout + * перепишет его перед первым кадром). */ + if (num_cells == 0 && num_sources > 0) { + strncpy(cell_keys[0], sources[0].key, 63); + cells[0].source_key = cell_keys[0]; + cells[0].x = 0; cells[0].y = 0; + cells[0].w = out_w; cells[0].h = out_h; + num_cells = 1; + } + signal(SIGINT, on_sig); signal(SIGTERM, on_sig); + /* Загружаем templates.json — если файла нет, остаются built-in. */ + if (templates_path) { + int n = cfc_layout_load_file(templates_path); + if (n <= 0) { + fprintf(stderr, "[grid_record] templates %s: failed (rc=%d), using built-in\n", + templates_path, n); + } + } + /* CUDA primary context. */ CUresult cr = cuInit(0); if (cr != CUDA_SUCCESS) { fprintf(stderr, "cuInit: %s\n", cu_err(cr)); return 1; } @@ -378,9 +434,22 @@ int main(int argc, char **argv) fprintf(stderr, "[grid_record] composer %dx%d, %d ячеек\n", out_w, out_h, num_cells); + /* --source: добавить в pool до motion-mode init. Источники cuframes + * стартуют здесь же, до первого compose'а. */ + for (int i = 0; i < num_sources; i++) { + cfc_composer_add_pool_source(comp, sources[i].key, + sources[i].frigate[0] ? sources[i].frigate : NULL, + sources[i].priority, + sources[i].zones[0] ? sources[i].zones : NULL); + } + if (motion_mode) { + cfc_composer_set_motion_mode(comp, 1, motion_ttl); + } + /* --layout NAME → applies named layout поверх --cell координат. Удобно * как default для ONVIF PTZ-управляемого composer'а (старт в quad, - * далее set_layout через ZMQ). */ + * далее set_layout через ZMQ). В motion-mode не работает (relayout + * перетирает на каждом кадре). */ if (initial_layout) { if (cfc_composer_set_layout(comp, initial_layout) != 0) { fprintf(stderr, "[grid_record] --layout '%s' unknown\n", initial_layout); @@ -477,13 +546,15 @@ int main(int argc, char **argv) fprintf(stderr, "\n"); } - /* Frigate MQTT subscriber (если задан --frigate-mqtt). */ + /* Frigate MQTT subscriber: запускаем если есть detection-cells + * (overlay'ные bbox'ы) ИЛИ motion-mode (auto-layout drivers). */ cfc_frigate_mqtt_t *frigate = NULL; - if (frigate_mqtt_host && num_detcells > 0) { + if (frigate_mqtt_host && (num_detcells > 0 || motion_mode)) { cfc_frigate_mqtt_config_t fc = { .host = frigate_mqtt_host, .port = frigate_mqtt_port, .username = mqtt_user, .password = mqtt_pass, .topic = frigate_topic, + .composer = motion_mode ? comp : NULL, /* pulse'ы только в motion-mode */ }; if (cfc_frigate_mqtt_create(&fc, &frigate) == 0) { for (int i = 0; i < num_detcells; i++) { diff --git a/include/cuframes_composer/composer.h b/include/cuframes_composer/composer.h index 45b381e..d2dab94 100644 --- a/include/cuframes_composer/composer.h +++ b/include/cuframes_composer/composer.h @@ -117,6 +117,55 @@ int cfc_composer_set_layout(cfc_composer_t *comp, const char *layout_name); * Возвращает NULL если cells выставлены вручную через --cell. */ const char *cfc_composer_current_layout(cfc_composer_t *comp); +/* ── Motion-driven auto layout (Phase 10) ──────────────────────────────── + * Composer держит pool из N (до 32) sources с priority. При включённом + * motion-mode на каждом compose'е выбирает active (last_motion_ms < TTL), + * сортирует по priority DESC, и применяет layout по count активных: + * 0 → top-1 by priority в single + * 1 → single + * 2 → dual_horizontal + * 3-4 → quad + * 5 → main_with_strip + * 6 → six_grid + * 7-9 → nine_grid + * 10-16 → sixteen_grid + * Сигналы motion поступают через cfc_composer_motion_pulse() — обычно из + * Frigate MQTT subscriber'а (frigate_mqtt.c). */ + +/* Добавить источник в pool (отдельно от --cell). Источник создаётся сразу, + * cuframes subscription стартует. Source доступен через motion-relayout. + * + * required_zones (опц.) — colon-separated whitelist для motion-фильтра: + * только события Frigate с current_zones intersect этот список считаются + * motion. Без него все события от frigate_camera считаются motion + * (полезно если камера без zones либо motion и так осмысленный). */ +int cfc_composer_add_pool_source( + cfc_composer_t *comp, + const char *cuframes_key, /* "cam-parking" */ + const char *frigate_camera, /* "parking_overview" для motion match */ + int priority, /* выше = важнее, top попадает в первую ячейку */ + const char *required_zones /* "parking_zone:canopy:private_area" или NULL */ +); + +/* Включить/выключить motion-mode. ttl_ms — сколько камера остаётся + * активной после последнего события (0 → default 45000). */ +int cfc_composer_set_motion_mode(cfc_composer_t *comp, int on, int ttl_ms); + +/* Узнать, включён ли motion-mode. */ +int cfc_composer_get_motion_mode(cfc_composer_t *comp); + +/* Зафиксировать факт motion от Frigate-камеры. Используется Frigate MQTT + * subscriber'ом. Если frigate_camera не найдена в pool — no-op. + * + * current_zones — список зон из after.current_zones события. Если у source + * задан required_zones — будет применён фильтр: motion засчитывается только + * если current_zones intersect required_zones. Если required_zones пуст или + * NULL — фильтр выключен. */ +int cfc_composer_motion_pulse(cfc_composer_t *comp, + const char *frigate_camera, + const char *const *current_zones, + int n_zones); + /* Получить layout статистику по источникам — для debug / health-репортов. */ typedef struct cfc_composer_health { int total; /* всего источников */ diff --git a/include/cuframes_composer/frigate_mqtt.h b/include/cuframes_composer/frigate_mqtt.h index 8b5596d..73413d5 100644 --- a/include/cuframes_composer/frigate_mqtt.h +++ b/include/cuframes_composer/frigate_mqtt.h @@ -16,6 +16,7 @@ #ifndef CUFRAMES_COMPOSER_FRIGATE_MQTT_H #define CUFRAMES_COMPOSER_FRIGATE_MQTT_H +#include "composer.h" #include "overlay.h" #ifdef __cplusplus @@ -28,6 +29,10 @@ typedef struct cfc_frigate_mqtt_config { const char *username; /* MQTT user — обычно тот же, что у health */ const char *password; const char *topic; /* default "frigate/events" */ + /* Если задан — каждое new/update event для любой камеры вызывает + * cfc_composer_motion_pulse(composer, after.camera). Это драйвер для + * motion-driven auto-layout (Phase 10). NULL = motion-pulse не шлём. */ + cfc_composer_t *composer; } cfc_frigate_mqtt_config_t; typedef struct cfc_frigate_mqtt cfc_frigate_mqtt_t; diff --git a/include/cuframes_composer/layouts.h b/include/cuframes_composer/layouts.h index 85fbdb4..c568341 100644 --- a/include/cuframes_composer/layouts.h +++ b/include/cuframes_composer/layouts.h @@ -1,12 +1,18 @@ -/* cuframes-composer — predefined layout templates. +/* cuframes-composer — модель layout'ов на 8×8 микро-сетке (Phase 11). * - * Phase 9 (task #194): runtime layout switching через ZMQ verb + ONVIF PTZ. + * Output (1920×1080) разбит на 8 cols × 8 rows микроячеек 240×135 px. + * Каждая микроячейка имеет aspect 16:9 — любой квадрат N×N микроячеек + * тоже 16:9 (без растяжения камер). * - * Layouts описаны normalized (0..1) — масштабируются к фактическому output - * width/height композитора при apply_to_cells(). Список синхронизирован с - * historic'ой `vf_cuda_grid.c` layouts[] (старый FFmpeg patch), что - * обеспечивает совместимость с Python controller'ом и предыдущими ONVIF - * presets. + * Cell template — прямоугольник {col, row, cs, rs} в микроячейках, + * с ролью CAMERA либо WIDGET. Camera-cells получают source из pool + * композитора по polю `order`: order=0 → main (top-priority active), + * order=1 → next и т.д. + * + * Layout template — набор cells одного экрана. Composer хранит таблицу + * built-in templates + опционально загружает override из JSON (Step 7). + * + * Phase 11 Step 1: единственный built-in `tpl_1` = одна cell 8×8 (single). * * Лицензия: LGPL-2.1+ */ @@ -18,25 +24,83 @@ extern "C" { #endif -#define CFC_LAYOUT_MAX_CELLS 16 -#define CFC_LAYOUT_MAX_NAME 32 +#define CFC_GRID_COLS 8 +#define CFC_GRID_ROWS 8 +#define CFC_LAYOUT_MAX_CELLS 32 +#define CFC_LAYOUT_MAX_NAME 32 +#define CFC_WIDGET_NAME_MAX 32 -typedef struct cfc_layout_cell { - float x, y, w, h; /* normalized fraction of output dims */ -} cfc_layout_cell_t; +typedef enum cfc_cell_role { + CFC_CELL_CAMERA = 0, + CFC_CELL_WIDGET = 1, +} cfc_cell_role_t; +/* Описание одной cell внутри layout-template'а. + * col, row — top-left угол в микроячейках (0..7) + * cs, rs — span в микроячейках (≥1, col+cs ≤ 8, row+rs ≤ 8) + * role — CAMERA или WIDGET + * order — для CAMERA: порядок placement'а активных камер + * (order=0 получает top-priority); игнорируется для WIDGET + * widget — для WIDGET: имя placeholder'а (текст подписи) */ +typedef struct cfc_cell { + int col, row; + int cs, rs; + cfc_cell_role_t role; + int order; + char widget[CFC_WIDGET_NAME_MAX]; +} cfc_cell_t; + +/* Описание layout-template'а: набор cells + metadata. */ typedef struct cfc_layout { - const char *name; - int nb_cells; - cfc_layout_cell_t cells[CFC_LAYOUT_MAX_CELLS]; + char name[CFC_LAYOUT_MAX_NAME]; + cfc_cell_t cells[CFC_LAYOUT_MAX_CELLS]; + int nb_cells; /* всего cells */ + int nb_camera_cells; /* кеш — для best-fit selection */ + int priority; /* tie-break при best-fit (выше = победитель) */ } cfc_layout_t; /* Найти layout по имени; NULL если нет. */ const cfc_layout_t *cfc_layout_find(const char *name); -/* Возвращает указатель на array всех layout'ов + их количество. */ +/* Возвращает указатель на массив всех загруженных layout'ов и их количество. */ const cfc_layout_t *cfc_layout_all(int *out_count); +/* Перевести {col,row,cs,rs} в pixel-координаты под output W×H. + * x = col * (W/8), w = cs * (W/8) (выровнено на чётные). + * NV12 требует чётных координат — встроено внутрь. */ +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); + +/* Загрузить templates из JSON-файла. При успехе сменяет реестр; при ошибке + * реестр остаётся прежним (или built-in, если ничего не было). + * Возвращает количество загруженных templates, либо отрицательное число + * (-1 = parse error, -2 = invalid schema, -3 = file not found). + * + * Hot-reload: после успешного вызова cfc_layout_find/cfc_layout_all + * возвращают новые template'ы. Композитор подхватывает на следующем кадре. + * + * JSON schema: + * { "version": 1, "grid_cols": 8, "grid_rows": 8, + * "templates": [ + * { "name": "tpl_1", "priority": 0, + * "cells": [ + * { "col":0, "row":0, "cs":8, "rs":8, + * "role":"camera", "order":0, + * "widget":"ha_chat" // для role=widget + * }, ... + * ] + * }, ... + * ] + * } */ +int cfc_layout_load_file(const char *path); + +/* Перезагрузить из последнего успешно загруженного файла. Если файла не + * было — возвращает -1. */ +int cfc_layout_reload(void); + +/* Получить путь к текущему загруженному файлу (NULL если built-in). */ +const char *cfc_layout_loaded_path(void); + #ifdef __cplusplus } #endif diff --git a/src/composer.c b/src/composer.c index 232ed25..618fed4 100644 --- a/src/composer.c +++ b/src/composer.c @@ -29,6 +29,9 @@ #include "../include/cuframes_composer/overlay.h" #include +#include +#include +#include #include #include #include @@ -37,6 +40,25 @@ #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; @@ -46,8 +68,8 @@ struct cfc_composer { char cell_keys[CFC_COMPOSER_MAX_CELLS][64]; int num_cells; - /* Источники по индексу cell'а (или NULL если cell — статичная картинка). */ - cfc_source_t *sources[CFC_COMPOSER_MAX_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 байт. */ @@ -66,8 +88,38 @@ struct cfc_composer { /* Текущий 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) @@ -114,10 +166,14 @@ static int compose_clear(cfc_composer_t *comp) static int compose_cell(cfc_composer_t *comp, int idx) { const cfc_composer_cell_t *cell = &comp->cells[idx]; - cfc_source_t *src = comp->sources[idx]; - if (!src) return 0; /* 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); @@ -157,6 +213,8 @@ int cfc_composer_create(const cfc_composer_config_t *cfg, cfc_composer_t **out) 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; @@ -188,17 +246,28 @@ int cfc_composer_create(const cfc_composer_config_t *cfg, cfc_composer_t **out) return -1; } - /* Создаём источники по одному на cell с source_key. */ + /* Pool: для каждой уникальной cell.source_key создаём подписку. + * Если key уже в pool (тот же source в нескольких cells) — реюзим. */ for (int i = 0; i < comp->num_cells; i++) { - if (!comp->cells[i].source_key) continue; + 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, i); - + snprintf(name, sizeof(name), "%s-%d", prefix, comp->pool_count); cfc_source_config_t scfg = { - .key = comp->cells[i].source_key, + .key = key, .consumer_name = name, .cuda_device = cfg->cuda_device, .reconnect_min_ms = cfg->reconnect_min_ms, @@ -206,13 +275,13 @@ int cfc_composer_create(const cfc_composer_config_t *cfg, cfc_composer_t **out) .stale_threshold_ms = cfg->stale_threshold_ms, .dead_threshold_ms = cfg->dead_threshold_ms, }; - if (cfc_source_create(&scfg, &comp->sources[i]) != 0) { + if (cfc_source_create(&scfg, &e->source) != 0) { fprintf(stderr, - "[cfc/composer] cfc_source_create failed для cell %d (%s)\n", - i, comp->cells[i].source_key); - /* Не fatal — DEAD source просто будет показывать blackout. - * Ячейка остаётся NULL → compose_cell сразу выйдет. */ + "[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(); @@ -229,6 +298,9 @@ int cfc_composer_compose(cfc_composer_t *comp, { 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++) { @@ -291,6 +363,13 @@ cfc_overlay_t *cfc_composer_find_overlay(cfc_composer_t *comp, const char *id) 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); @@ -301,19 +380,11 @@ int cfc_composer_set_layout(cfc_composer_t *comp, const char *layout_name) 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; - /* Применяем нормализованные координаты к каждой существующей ячейке. - * source_key и индексы сохраняются. Если у layout'а меньше cells чем у - * composer'а — лишние оставляем с нулевой геометрией (compose_cell - * увидит w<=0 и пропустит). */ + /* Микро-сетка → pixel coords для каждой cell. Source_key привязок не + * меняем (Step 2+ добавит role/order распределение). */ for (int i = 0; i < n_apply; i++) { - int x = (int)(lay->cells[i].x * W + 0.5f); - int y = (int)(lay->cells[i].y * H + 0.5f); - int w = (int)(lay->cells[i].w * W + 0.5f); - int h = (int)(lay->cells[i].h * H + 0.5f); - /* Чётные координаты — требование 4:2:0 NV12. */ - x &= ~1; y &= ~1; w &= ~1; h &= ~1; - if (x + w > W) w = W - x; - if (y + h > H) h = H - y; + 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; @@ -338,18 +409,336 @@ const char *cfc_composer_current_layout(cfc_composer_t *comp) 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->num_cells; - for (int i = 0; i < comp->num_cells; i++) { - if (!comp->sources[i]) { + 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->sources[i], &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; @@ -362,12 +751,13 @@ int cfc_composer_get_health(cfc_composer_t *comp, cfc_composer_health_t *out) int cfc_composer_destroy(cfc_composer_t *comp) { if (!comp) return 0; - for (int i = 0; i < comp->num_cells; i++) { - if (comp->sources[i]) cfc_source_destroy(comp->sources[i]); + 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/control.c b/src/control.c index 165ffdf..10c031b 100644 --- a/src/control.c +++ b/src/control.c @@ -177,20 +177,82 @@ static void cmd_set_layout(void *sock, cfc_composer_t *comp, struct json_object send_json(sock, o); } +static struct json_object *template_to_json(const cfc_layout_t *l) +{ + struct json_object *t = json_object_new_object(); + json_object_object_add(t, "name", json_object_new_string(l->name)); + json_object_object_add(t, "priority", json_object_new_int(l->priority)); + json_object_object_add(t, "nb_cells", json_object_new_int(l->nb_cells)); + json_object_object_add(t, "nb_camera_cells", json_object_new_int(l->nb_camera_cells)); + struct json_object *cells = json_object_new_array(); + for (int i = 0; i < l->nb_cells; i++) { + const cfc_cell_t *c = &l->cells[i]; + struct json_object *jc = json_object_new_object(); + json_object_object_add(jc, "col", json_object_new_int(c->col)); + json_object_object_add(jc, "row", json_object_new_int(c->row)); + json_object_object_add(jc, "cs", json_object_new_int(c->cs)); + json_object_object_add(jc, "rs", json_object_new_int(c->rs)); + json_object_object_add(jc, "role", + json_object_new_string(c->role == CFC_CELL_WIDGET ? "widget" : "camera")); + json_object_object_add(jc, "order", json_object_new_int(c->order)); + if (c->widget[0]) { + json_object_object_add(jc, "widget", json_object_new_string(c->widget)); + } + json_object_array_add(cells, jc); + } + json_object_object_add(t, "cells", cells); + return t; +} + static void cmd_list_layouts(void *sock) { int n = 0; const cfc_layout_t *all = cfc_layout_all(&n); struct json_object *arr = json_object_new_array(); for (int i = 0; i < n; i++) { - struct json_object *l = json_object_new_object(); - json_object_object_add(l, "name", json_object_new_string(all[i].name)); - json_object_object_add(l, "nb_cells", json_object_new_int(all[i].nb_cells)); - json_object_array_add(arr, l); + json_object_array_add(arr, template_to_json(&all[i])); } struct json_object *o = json_object_new_object(); json_object_object_add(o, "ok", json_object_new_boolean(1)); json_object_object_add(o, "layouts", arr); + const char *p = cfc_layout_loaded_path(); + json_object_object_add(o, "source", + json_object_new_string(p ? p : "built-in")); + send_json(sock, o); +} + +static void cmd_get_template(void *sock, struct json_object *req) +{ + const char *name = get_str(req, "name"); + if (!name) { send_error(sock, "missing 'name'"); return; } + const cfc_layout_t *l = cfc_layout_find(name); + if (!l) { send_error(sock, "template not found"); return; } + struct json_object *o = json_object_new_object(); + json_object_object_add(o, "ok", json_object_new_boolean(1)); + json_object_object_add(o, "template", template_to_json(l)); + send_json(sock, o); +} + +static void cmd_reload_templates(void *sock, struct json_object *req) +{ + const char *path = get_str(req, "path"); + int r; + if (path) r = cfc_layout_load_file(path); + else r = cfc_layout_reload(); + if (r < 0) { + const char *msg = "reload failed"; + if (r == -1) msg = "JSON parse error"; + else if (r == -2) msg = "schema invalid"; + else if (r == -3) msg = "file not found / no previous load"; + send_error(sock, msg); + return; + } + struct json_object *o = json_object_new_object(); + json_object_object_add(o, "ok", json_object_new_boolean(1)); + json_object_object_add(o, "templates", json_object_new_int(r)); + const char *p = cfc_layout_loaded_path(); + json_object_object_add(o, "source", + json_object_new_string(p ? p : "built-in")); send_json(sock, o); } @@ -204,6 +266,30 @@ static void cmd_get_layout(void *sock, cfc_composer_t *comp) send_json(sock, o); } +static void cmd_set_motion_mode(void *sock, cfc_composer_t *comp, + struct json_object *req) +{ + int on = get_int(req, "on", 1); + int ttl = get_int(req, "ttl_ms", 0); + if (cfc_composer_set_motion_mode(comp, on, ttl) != 0) { + send_error(sock, "set_motion_mode failed"); + return; + } + struct json_object *o = json_object_new_object(); + json_object_object_add(o, "ok", json_object_new_boolean(1)); + json_object_object_add(o, "motion_mode", json_object_new_int(on ? 1 : 0)); + send_json(sock, o); +} + +static void cmd_get_motion_mode(void *sock, cfc_composer_t *comp) +{ + struct json_object *o = json_object_new_object(); + json_object_object_add(o, "ok", json_object_new_boolean(1)); + json_object_object_add(o, "motion_mode", + json_object_new_int(cfc_composer_get_motion_mode(comp))); + send_json(sock, o); +} + static void cmd_list_overlays(void *sock, cfc_composer_t *comp) { struct json_object *arr = json_object_new_array(); @@ -242,6 +328,10 @@ static void dispatch(void *sock, cfc_composer_t *comp, const char *json_str, siz else if (!strcmp(cmd, "set_layout")) cmd_set_layout(sock, comp, req); else if (!strcmp(cmd, "list_layouts")) cmd_list_layouts(sock); else if (!strcmp(cmd, "get_layout")) cmd_get_layout(sock, comp); + else if (!strcmp(cmd, "set_motion_mode")) cmd_set_motion_mode(sock, comp, req); + else if (!strcmp(cmd, "get_motion_mode")) cmd_get_motion_mode(sock, comp); + else if (!strcmp(cmd, "get_template")) cmd_get_template(sock, req); + else if (!strcmp(cmd, "reload_templates")) cmd_reload_templates(sock, req); else send_error(sock, "unknown cmd"); out: diff --git a/src/frigate_mqtt.c b/src/frigate_mqtt.c index 7090796..db6e92b 100644 --- a/src/frigate_mqtt.c +++ b/src/frigate_mqtt.c @@ -104,8 +104,29 @@ static void parse_event(cfc_frigate_mqtt_t *f, const char *payload) const char *camera = json_object_get_string(jcam); const char *event_id = json_object_get_string(jid); + /* Распарсить current_zones — нужны и для motion_pulse, и для bbox-фильтра. */ + const char *zone_buf[16]; + int n_zones = 0; + if (jzones && json_object_is_type(jzones, json_type_array)) { + int len = (int)json_object_array_length(jzones); + if (len > 16) len = 16; + for (int i = 0; i < len; i++) { + struct json_object *jz = json_object_array_get_idx(jzones, i); + if (jz && json_object_is_type(jz, json_type_string)) { + zone_buf[n_zones++] = json_object_get_string(jz); + } + } + } + + /* Motion pulse — для ВСЕХ камер из pool'а композитора, независимо + * от того есть ли detbox overlay. Это драйвер auto-layout (Phase 10). + * Per-source zone-filter применится внутри composer.motion_pulse. */ + if (f->cfg.composer && strcmp(type, "end") != 0) { + cfc_composer_motion_pulse(f->cfg.composer, camera, zone_buf, n_zones); + } + cfc_overlay_t *ov = lookup_overlay(f, camera); - if (!ov) { json_object_put(root); return; } /* не наша камера */ + if (!ov) { json_object_put(root); return; } /* не наша камера для overlay */ atomic_fetch_add(&f->events_received, 1); @@ -133,21 +154,8 @@ static void parse_event(cfc_frigate_mqtt_t *f, const char *payload) const char *label = jlabel ? json_object_get_string(jlabel) : ""; double frame_time = jft ? json_object_get_double(jft) : 0.0; - /* Zone-filter — отсев street-флуда. Frigate 0.17 schema не даёт native - * objects.filters.required_zones (Pydantic extra-inputs-not-permitted → - * safe mode), поэтому фильтруем на стороне subscriber'а. */ - const char *zone_buf[16]; - int n_zones = 0; - if (jzones && json_object_is_type(jzones, json_type_array)) { - int len = (int)json_object_array_length(jzones); - if (len > 16) len = 16; - for (int i = 0; i < len; i++) { - struct json_object *jz = json_object_array_get_idx(jzones, i); - if (jz && json_object_is_type(jz, json_type_string)) { - zone_buf[n_zones++] = json_object_get_string(jz); - } - } - } + /* Zone-filter overlay'я (current_zones уже распарсены выше). Отсев + * street-флуда — Frigate 0.17 не даёт native objects.filters.required_zones. */ if (!cfc_overlay_detbox_match_zones(ov, zone_buf, n_zones)) { json_object_put(root); return; diff --git a/src/layouts.c b/src/layouts.c index 91b60b0..9b364c2 100644 --- a/src/layouts.c +++ b/src/layouts.c @@ -1,100 +1,275 @@ -/* Predefined layouts table — port из vf_cuda_grid.c (старый FFmpeg patch). +/* Layout-templates на 8×8 микро-сетке (Phase 11). * - * Layouts normalized [0..1], apply'ятся к фактическому output разрешению - * композитора в момент set_layout — не привязаны к конкретным cell coords. + * 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 const cfc_layout_t g_layouts[] = { - { - "single", 1, - { {0.0f, 0.0f, 1.0f, 1.0f} } - }, - { - "dual_horizontal", 2, - { {0.0f, 0.0f, 0.5f, 1.0f}, {0.5f, 0.0f, 0.5f, 1.0f} } - }, - { - "dual_vertical", 2, - { {0.0f, 0.0f, 1.0f, 0.5f}, {0.0f, 0.5f, 1.0f, 0.5f} } - }, - { - "quad", 4, - { - {0.0f, 0.0f, 0.5f, 0.5f}, {0.5f, 0.0f, 0.5f, 0.5f}, - {0.0f, 0.5f, 0.5f, 0.5f}, {0.5f, 0.5f, 0.5f, 0.5f}, - } - }, - { - /* Main 2/3 width слева + 3 preview справа сверху вниз. */ - "main_plus_preview", 4, - { - {0.0f, 0.0f, 2.0f/3, 1.0f}, - {2.0f/3, 0.0f, 1.0f/3, 1.0f/3}, - {2.0f/3, 1.0f/3, 1.0f/3, 1.0f/3}, - {2.0f/3, 2.0f/3, 1.0f/3, 1.0f/3}, - } - }, - { - "six_grid", 6, - { - {0.0f, 0.0f, 1.0f/3, 0.5f}, {1.0f/3, 0.0f, 1.0f/3, 0.5f}, {2.0f/3, 0.0f, 1.0f/3, 0.5f}, - {0.0f, 0.5f, 1.0f/3, 0.5f}, {1.0f/3, 0.5f, 1.0f/3, 0.5f}, {2.0f/3, 0.5f, 1.0f/3, 0.5f}, - } - }, - { - "nine_grid", 9, - { - {0.0f, 0.0f, 1.0f/3, 1.0f/3}, {1.0f/3, 0.0f, 1.0f/3, 1.0f/3}, {2.0f/3, 0.0f, 1.0f/3, 1.0f/3}, - {0.0f, 1.0f/3, 1.0f/3, 1.0f/3}, {1.0f/3, 1.0f/3, 1.0f/3, 1.0f/3}, {2.0f/3, 1.0f/3, 1.0f/3, 1.0f/3}, - {0.0f, 2.0f/3, 1.0f/3, 1.0f/3}, {1.0f/3, 2.0f/3, 1.0f/3, 1.0f/3}, {2.0f/3, 2.0f/3, 1.0f/3, 1.0f/3}, - } - }, - { - "sixteen_grid", 16, - { - {0.00f, 0.00f, 0.25f, 0.25f}, {0.25f, 0.00f, 0.25f, 0.25f}, {0.50f, 0.00f, 0.25f, 0.25f}, {0.75f, 0.00f, 0.25f, 0.25f}, - {0.00f, 0.25f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.25f, 0.25f}, {0.50f, 0.25f, 0.25f, 0.25f}, {0.75f, 0.25f, 0.25f, 0.25f}, - {0.00f, 0.50f, 0.25f, 0.25f}, {0.25f, 0.50f, 0.25f, 0.25f}, {0.50f, 0.50f, 0.25f, 0.25f}, {0.75f, 0.50f, 0.25f, 0.25f}, - {0.00f, 0.75f, 0.25f, 0.25f}, {0.25f, 0.75f, 0.25f, 0.25f}, {0.50f, 0.75f, 0.25f, 0.25f}, {0.75f, 0.75f, 0.25f, 0.25f}, - } - }, - { - "panoramic", 1, - { {0.0f, 0.0f, 1.0f, 1.0f} } - }, - { - /* Main 16:9 (75%×75%) + 3 preview right + bottom info strip. */ - "main_with_strip", 5, - { - {0.0f, 0.0f, 0.75f, 0.75f}, - {0.75f, 0.0f, 0.25f, 0.25f}, - {0.75f, 0.25f, 0.25f, 0.25f}, - {0.75f, 0.5f, 0.25f, 0.25f}, - {0.0f, 0.75f, 1.0f, 0.25f}, - } - }, -}; +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 const int g_layouts_count = (int)(sizeof(g_layouts) / sizeof(g_layouts[0])); +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)) return &g_layouts[i]; + if (!strcmp(g_layouts[i].name, name)) { found = &g_layouts[i]; break; } } - return NULL; + 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; - return g_layouts; + 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; }