# Design: `vf-cuda-grid` — GPU-native video grid composer with control plane sidecar **Repo (рекомендуемое имя):** `gx/vf-cuda-grid` Альтернативы которые рассматривал: - `gx/vf_cuda_grid` — соответствует именованию FFmpeg-фильтра, но дефис в repo-name удобнее для URL и CLI. **Reject.** - `gx/cuda-grid` — короче, но скрывает что это FFmpeg-filter (а не standalone tool). **Reject.** - `gx/ffmpeg-cuda-grid` — точное описание, но префикс `ffmpeg-` намекает на fork всего FFmpeg, а у нас фильтр-патч. **Reject.** - **`gx/vf-cuda-grid` ✅** — `vf-` префикс — это конвенция FFmpeg video-filter (как `vf_scale_cuda`, `vf_overlay_cuda`), сразу понятно что это; дефис — repo-friendly. Repo содержит **all three components** (filter source + sidecar + docs/examples) — это monorepo product. Дробить на 2-3 repo рано: компоненты тесно связаны по протоколу, и Phase 1-3 удобнее ревьюить совместно. Когда controller станет multi-product (см. §14 — overlap с `gx/cctv#24`), его можно extract'нуть в `gx/grid-controller` (sub-stable surface). --- ## 1. High-level architecture ``` ┌──────────────────────────────────────────────────────────────────────────────┐ │ HOST PROCESS (FFmpeg) │ │ │ │ cuframes://cam1 ─┐ │ │ cuframes://cam2 ─┼─►┌──────────────────┐ │ │ cuframes://cam3 ─┤ │ vf_cuda_grid │ ───► scale_cuda? ──► h264_nvenc │ │ cuframes://cam4 ─┘ │ (instance #1) │ │ │ │ ├─►│ target: TV-1 │ ▼ │ │ │ └────────▲─────────┘ RTSP/SRT │ │ │ │ side data (overlays) │ │ ├─►┌────────┴─────────┐ │ │ │ │ vf_cuda_grid │ ───► h264_nvenc ──► RTSP │ │ │ │ (instance #2) │ │ │ │ │ target: TV-2 │ │ │ │ └────────▲─────────┘ │ │ │ │ │ │ ├─►┌────────┴─────────┐ │ │ │ │ vf_cuda_grid │ ───► h264_nvenc ──► WebRTC │ │ │ │ (instance #3) │ (privacy-public) │ │ │ └────────▲─────────┘ │ │ │ │ │ │ │ ┌────────┴────────┐ │ │ │ │ zmq filter │◄─── tcp://127.0.0.1:5555 (commands) │ │ │ └─────────────────┘ │ │ │ │ │ audio ──────────┴─► amix / sidechaincompress (стандартные FFmpeg filters) │ └────────────────────────────────────────────────────────────────────▲──────────┘ │ process_command via zmq │ ┌────────────────────────────────────────────────────────────────────┴──────────┐ │ cuda-grid-controller (sidecar) │ │ │ │ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │ │ │ HTTP/REST + SSE │ │ MQTT (paho) │ │ ZeroMQ pub/sub │ │ │ │ (FastAPI/aiohttp) │ │ + HA Discovery │ │ events outbound │ │ │ └─────────┬──────────┘ └─────────┬──────────┘ └─────────▲──────────┘ │ │ └──────────────┬─────────┘ │ │ │ ▼ │ │ │ ┌────────────────────┐ │ │ │ │ Command Router │──────────────────────►│ │ │ │ (idempotency, │ serialised events │ │ │ │ conflict-resol) │ │ │ │ └─────────┬──────────┘ │ │ │ ▼ │ │ │ ┌────────────────────┐ ┌─────────────────────┐ ┌──────────┴────────────┐ │ │ │ State Store │ │ Layout Registry │ │ Event Bus (internal) │ │ │ │ (in-mem + │ │ (global, versioned) │ │ (asyncio.Queue / Go │ │ │ │ JSON snapshot) │ │ │ │ channel) │ │ │ └────────────────────┘ └─────────────────────┘ └───────────────────────┘ │ │ ▼ │ │ ┌────────────────────┐ │ │ │ FFmpeg ZMQ Client │──► tcp://127.0.0.1:5555 (commands) │ │ │ + Side-Data Pusher│──► AVFrame side-data injection │ │ └────────────────────┘ (через named pipe / shared mem) │ └───────────────────────────────────────────────────────────────────────────────┘ ``` **State ownership:** | Что | Owner | Persistence | Почему | |---|---|---|---| | Active layout per instance | filter (in-process) | none | hot path, нужно низкое latency | | Layout registry (definitions) | controller | JSON-snapshot + version'ed in-mem | переживает restart FFmpeg, может share между filter-instances | | Camera roster per instance | controller | JSON | runtime modify, push to filter via cmd | | Overlay state (live elements) | controller | in-mem | ephemeral, восстанавливается с events | | Audio routing state | controller | JSON | сложная state machine, нужно auditable | | Statistics (fps, switches, errors) | controller (aggregated) | in-mem + Prometheus | observability | **Кто кому owner:** controller — single source of truth для **declared intent** (что должно быть). Filter — owner **executing state** (что сейчас работает). При рассинхроне controller reconcile'ит, filter — нет. **Repo structure:** ``` gx/vf-cuda-grid/ ├── README.md ├── ROADMAP.md ├── LICENSE # LGPL (для filter — наследуется от FFmpeg) + │ # MIT (для controller — отдельный LICENSE-controller) ├── CHANGELOG.md ├── filter/ # vf_cuda_grid.c + CUDA kernels + FFmpeg patch │ ├── vf_cuda_grid.c │ ├── vf_cuda_grid_kernels.cu │ ├── vf_cuda_grid_overlays.cu │ ├── ffmpeg-7.1-vf_cuda_grid.patch │ └── README.md # как применить patch к ffmpeg-patched ├── controller/ # cuda-grid-controller │ ├── pyproject.toml # (или go.mod если Go — см. §10) │ ├── src/cuda_grid_controller/ │ │ ├── api/ # HTTP, MQTT, ZMQ entrypoints │ │ ├── domain/ # layouts, overlays, state — pure │ │ ├── adapters/ # ffmpeg-zmq, mqtt, ha-discovery │ │ └── orchestration/ # audio rules, event handlers │ └── tests/ ├── schema/ # JSON-schema для layouts, overlays, events │ ├── layout.schema.json │ ├── overlay.schema.json │ └── event.schema.json ├── examples/ │ ├── frigate-bridge/ # Frigate MQTT events → grid commands │ ├── cctv-processor-migrate/ │ └── home-assistant/ ├── docs/ │ ├── architecture.md # this document, refined │ ├── filter-api.md │ ├── controller-api.md │ ├── layout-dsl.md │ ├── overlay-protocol.md │ └── audio-orchestration.md └── deploy/ ├── docker/ └── systemd/ ``` **Главный архитектурный принцип:** *filter знает только про композицию пикселей в текущем кадре; всё что про "почему такой layout, чьи events, какие правила" — в controller*. Это позволяет filter оставаться small, fast, upstreamable, а всю бизнес-логику итерировать в Python/Go без пересборки FFmpeg. --- ## 2. Component design ### 2.1 `vf_cuda_grid` filter API **Filter declaration:** ```c const AVFilter ff_vf_cuda_grid = { .name = "cuda_grid", .description = NULL_IF_CONFIG_SMALL("GPU-native multi-input grid composer"), .priv_class = &cuda_grid_class, .priv_size = sizeof(CudaGridContext), .flags = AVFILTER_FLAG_DYNAMIC_INPUTS | AVFILTER_FLAG_HWDEVICE, .nb_inputs = 0, // declared via options .nb_outputs = 1, FILTER_QUERY_FUNC(query_formats), // AV_PIX_FMT_CUDA only FILTER_INPUTS(NULL), // dynamic FILTER_OUTPUTS(cuda_grid_outputs), .process_command = process_command, // runtime control }; ``` **Filter options (CLI):** ``` cuda_grid= inputs=4: # сколько inputs (как у hstack/xstack) layout=quad: # initial layout name (из registry или inline) output_size=1920x1080: layout_file=/etc/grids.json: # static layout registry instance_id=tv-living-room: # unique ID этого instance в graph zmq_addr=tcp://127.0.0.1:5555: # whose process_command'ы слушать privacy_profile=public: # named profile фильтрующий overlays fps=25 # output fps (resampling если inputs разные) ``` **Multi-instance в одном filter_complex:** каждый `cuda_grid` имеет `instance_id` — controller адресует команды по нему. Inputs **shared** через FFmpeg native `split` filter: ``` [cam1][cam2][cam3][cam4]split=4[a1][b1][c1][d1][a2][b2][c2][d2][a3][b3][c3][d3]; [a1][b1][c1][d1]cuda_grid=inputs=4:instance_id=tv1:layout=quad[out1]; [a2][b2][c2][d2]cuda_grid=inputs=4:instance_id=tv2:layout=nine_grid[out2]; [a3][b3][c3][d3]cuda_grid=inputs=4:instance_id=public:privacy_profile=public:layout=quad[out3] ``` `split` уже работает с CUDA frames (по ref-count'у). Декодируем 4 камеры один раз, ref-share в 3 instances. **`process_command` API (runtime):** | Command | Args | Side effect | |---|---|---| | `set_layout` | `name=` | сменить активный layout у instance | | `set_layout_transition` | `name=<>;duration_ms=500;type=fade` | переключение с cross-fade | | `bind_cell` | `cell=;camera=` | per-cell camera→input mapping | | `swap_cells` | `a=;b=` | поменять две ячейки местами | | `set_privacy_profile` | `profile=` | переключить фильтрацию overlays | | `set_overlay` | `id=<>;json=<...>` | add/replace overlay | | `clear_overlay` | `id=<>` | delete overlay | | `clear_all_overlays` | `cell=?` | flush либо все, либо конкретной cell | | `set_text` | `id=<>;text=` | быстрый shortcut для text-overlay update | Commands передаются через FFmpeg `zmq` filter в filter graph (он routes по filter target). Filter implement'ит `process_command` (FFmpeg signature: `int (*process_command)(AVFilterContext*, const char *cmd, const char *arg, char *res, int res_len, int flags)`). **Ограничение FFmpeg `process_command`:** args — single string. Для сложных overlay payloads используем JSON-string в `arg`, либо **AVFrame side data** для bulk-данных (см. §5). **Internal context:** ```c typedef struct CudaGridContext { const AVClass *class; // config int nb_inputs; int output_w, output_h; char *instance_id; char *layout_file; char *zmq_addr; char *privacy_profile; // CUDA AVBufferRef *hw_device_ref; // CUDA device AVBufferRef *hw_frames_ref; // output frames pool CUcontext cu_ctx; CUstream cu_stream; // layouts LayoutRegistry *registry; // shared (см. §9) Layout *active_layout; // current Layout *prev_layout; // для cross-fade LayoutTransition transition; // active transition state // per-cell camera binding (cell_idx → input_idx) int cell_to_input[MAX_CELLS]; // overlays OverlayStore *overlays; // per-instance state GpuOverlayCache *gpu_cache; // pre-uploaded textures, glyph atlases // input frame queue (lock-step buffering — см. §3) InputFrameQueue queues[MAX_INPUTS]; int64_t last_pts; // statistics GridStats stats; } CudaGridContext; ``` ### 2.2 `cuda-grid-controller` sidecar — модули ``` controller/src/cuda_grid_controller/ ├── domain/ │ ├── layout.py # Layout, Cell, LayoutRegistry — pure value-objects (pydantic) │ ├── overlay.py # Overlay primitives — pydantic discriminated union │ ├── instance.py # FilterInstance — state of one cuda_grid filter │ ├── audio_state.py # AudioRoutingState, DuckingRule │ └── events.py # Event taxonomy (см. §7) ├── api/ │ ├── http/ # FastAPI app, SSE endpoint │ ├── mqtt/ # paho-mqtt handlers + HA Discovery │ ├── zmq/ # asyncio pyzmq pub-socket (events out) │ └── schemas/ # request/response models ├── adapters/ │ ├── ffmpeg_zmq_client.py # отправка process_command в FFmpeg │ ├── side_data_pusher.py # AVFrame side data — через named pipe / unix socket │ ├── ha_discovery.py # MQTT discovery config publisher │ └── frigate_bridge.py # subscribe Frigate MQTT events → translate ├── orchestration/ │ ├── command_router.py # routes commands из всех источников в один pipeline │ ├── conflict_resolver.py # см. §7 — last-write-wins + per-source priority │ ├── audio_orchestrator.py # state machine: domofon→duck→swap_screen→restore │ └── overlay_lifecycle.py # TTL, throttling, debouncing для charts/chats ├── stores/ │ ├── memory.py # MemoryStateStore (default) │ └── redis.py # RedisStateStore (multi-controller HA, future) └── cli.py # entrypoint, config loading ``` **Разделение responsibilities:** - `domain/` — pure Python, no I/O, тестируется без mocks. Pydantic-models с validation. - `api/` — три entrypoint'а (HTTP, MQTT, ZMQ-pub), все нормализуют входной command в внутренний `Command` DTO и кладут в `command_router`. - `orchestration/` — слышит events, применяет правила, генерирует commands. - `adapters/` — outbound side (в FFmpeg и в Frigate). Может быть mocked в tests. **Hot reload config:** controller подписан на signal SIGHUP → re-read JSON конфигов layouts/cameras без restart. State в памяти не теряется. --- ## 3. Composition algorithm ### 3.1 CUDA pipeline для одного output frame ``` для каждого output frame (на target fps): 1. собрать N input frames (по input queues, см. lock-step ниже) 2. allocate output AVFrame (NV12, CUDA) из hw_frames_ref pool 3. clear background (CUDA memset → background_color) 4. for each visible cell c in active_layout: input_idx = cell_to_input[c.cell_idx] in_frame = input_frames[input_idx] src_rect, dst_rect = compute_rects(in_frame, c, output_w, output_h) if c.size == in_frame.size and c.no_scale: # fast-path: pure NV12 region memcpy (Y и UV planes отдельно) cuMemcpy2DAsync(...) else: # scale-blit: nppiResizeYUV или custom bilinear kernel launch_scale_kernel(in_frame, out_frame, src_rect, dst_rect, stream) if c.border or motion_indication: launch_border_kernel(out_frame, c.rect, c.color, c.width, stream) 5. apply overlays (см. §5): sort overlays by z_order for each overlay o: if o.cell_filter and c not in matching cells: skip if privacy_profile_excludes(o): skip launch_overlay_blit_kernel(out_frame, o.gpu_texture, o.rect, o.alpha, stream) 6. if transition.active: blend(prev_layout_frame, current_layout_frame, t) — линейное мixing двух proxy buffers 7. cuStreamSynchronize (или semaphore с следующим filter — см. §3.4) 8. push out_frame в output ``` ### 3.2 CUDA kernels (что писать, что брать готовое) | Operation | Implementation | Зачем custom (если custom) | |---|---|---| | NV12 region memcpy (no-scale) | `cuMemcpy2DAsync` × 2 (Y + UV planes) | стандартно | | Bilinear scale NV12 → NV12 | **NPP `nppiResize_8u_C1R`** для Y, отдельно UV | NPP — NVIDIA-provided, оптимально; не пишем сами | | Border drawing | custom kernel (rect outline) | мелочь, NPP overkill | | Background clear | `cuMemsetD8Async` per plane | стандартно | | Alpha-blit RGBA texture → NV12 | **custom kernel** | конверсия RGBA→YUV + alpha-mix, NPP не делает напрямую; ~80 строк CUDA | | Glyph blit (text) | custom kernel: 8-bit alpha mask + color | text — alpha-only текстура, blit с color tint | | Dim/darken area | custom kernel: multiply Y by factor | trivial | | Linear blend (cross-fade) | custom kernel: `out = a*frame1 + (1-a)*frame2` | trivial | **Принцип:** где есть NPP — берём NPP (NVIDIA уже оптимизировала). Где нет — ~50-100-строчные kernels с unit tests на known fixtures. ### 3.3 Lock-step inputs — что если не все inputs прибыли FFmpeg filter API — pull-based: framework сам забирает frame через `ff_inlink_consume_frame()`. Но при N inputs они могут идти **рассинхронно** (камеры в RTSP не synced). **Стратегия — adaptive PTS bucket'ing:** 1. Каждый input имеет ring queue (size = `max_input_lag_frames`, default 4). 2. Output frame генерируется по target_fps (например 25 fps → каждые 40ms). 3. Для каждого output frame: для каждого input берём frame с PTS ближайшим к `output_pts ± tolerance` (default ±20ms). 4. Если для input нет свежего frame → **stale-frame policy** (configurable): - `keep_last` (default) — рендерим last seen frame этого input - `black` — заливаем cell черным + label "NO SIGNAL" - `freeze_and_warn` — keep_last + красная рамка после `stale_timeout_ms` 5. Если **все** inputs stale → publish event `inputs_starved`, output продолжается с black background. **Why pull-based work fine here:** output PTS — наш wall-clock; inputs — лучшие из доступных. Это hard real-time scenario, не lossless mux. **Per-cell stale event:** controller получает `cell_stale{cell=N, camera=cam2, last_seen_ms=3400}` → MQTT publish, HA reagирует "камера 2 не отвечает". ### 3.4 Async vs sync CUDA execution - Каждый instance имеет свой `CUstream` (parallel composition между instances). - Внутри одного instance: все kernels на одном stream + asynchronous, финальный `cuEventRecord` → output AVFrame получает `AVCUDADeviceContextInternal::ready_event` (FFmpeg уже умеет это в `hwcontext_cuda`). - Следующий filter (`scale_cuda`, `h264_nvenc`) делает `cuStreamWaitEvent` — настоящий zero-copy GPU pipeline без CPU блока. --- ## 4. Layout DSL ### 4.1 JSON Schema (layouts.json) ```json { "version": "1", "schema": "https://git.goldix.org/gx/vf-cuda-grid/schema/layout.schema.json", "defaults": { "background": "#000000", "border": { "width": 2, "color": "#FFFFFF" }, "label": { "enabled": true, "position": "top_left", "font_size": 16 } }, "layouts": { "quad": { "title": "2×2 quad", "type": "predefined", "cells": [ { "id": 0, "x": 0.0, "y": 0.0, "w": 0.5, "h": 0.5 }, { "id": 1, "x": 0.5, "y": 0.0, "w": 0.5, "h": 0.5 }, { "id": 2, "x": 0.0, "y": 0.5, "w": 0.5, "h": 0.5 }, { "id": 3, "x": 0.5, "y": 0.5, "w": 0.5, "h": 0.5 } ] }, "main_plus_preview": { "title": "Main + 3 previews", "type": "predefined", "cells": [ { "id": 0, "x": 0.00, "y": 0.00, "w": 0.75, "h": 1.00, "role": "main" }, { "id": 1, "x": 0.75, "y": 0.00, "w": 0.25, "h": 0.33, "role": "preview" }, { "id": 2, "x": 0.75, "y": 0.33, "w": 0.25, "h": 0.33, "role": "preview" }, { "id": 3, "x": 0.75, "y": 0.66, "w": 0.25, "h": 0.34, "role": "preview" } ] }, "custom_3x4_with_dim": { "title": "Mixed", "type": "user", "cells": [ { "id": 0, "x": 0.00, "y": 0.00, "w": 0.50, "h": 0.50, "z_index": 0, "cell_overlays": ["dim_if_no_motion"] }, { "id": 1, "x": 0.50, "y": 0.00, "w": 0.50, "h": 0.50, "fit": "contain", "background": "#101010" } ] } }, "default_layout": "quad", "instances": { "tv-living-room": { "default_layout": "main_plus_preview", "privacy_profile": "private" }, "tv-kitchen": { "default_layout": "quad", "privacy_profile": "private" }, "public-stream": { "default_layout": "quad", "privacy_profile": "public" } }, "privacy_profiles": { "private": { "overlays_allow": ["*"] }, "public": { "overlays_deny": ["lpr_text", "face_name", "person_count"] } } } ``` **Cell fields:** - `id` (int) — slot в layout, на который controller `bind_cell` шлёт camera. - `x,y,w,h` (float 0..1) — нормализованные координаты (consistent с текущим GridComposer). - `role` (string, optional) — semantic hint (`main`, `preview`, `pip`). Controller использует для auto-selection. - `fit` (`cover` | `contain` | `stretch`, default `cover`) — поведение при aspect mismatch. - `background` (hex) — заливка cell за пределами scaled frame (при `contain`). - `z_index` (int) — для overlapping layouts (PiP). - `cell_overlays` (string[]) — overlay-templates применяемые автоматически. **Camera binding** — **не в layout**. Layout определяет геометрию; camera→cell mapping — runtime state хранимый в controller per-instance. При `set_layout` controller `bind_cell` mapping автоматически: если layout имеет cells `[0,1,2,3]` и camera roster `[cam_front, cam_yard, cam_door, cam_garage]` — bind by index. User может override. ### 4.2 In-memory representation (filter side) ```c typedef struct LayoutCell { int id; float x, y, w, h; int z_index; uint32_t bg_color; uint32_t border_color; int border_width; int fit_mode; // CELL_FIT_COVER/CONTAIN/STRETCH // resolved pixel rect (cached, invalidated при change output_size) int px_x, px_y, px_w, px_h; } LayoutCell; typedef struct Layout { char name[64]; int nb_cells; LayoutCell cells[MAX_CELLS]; // MAX_CELLS = 64 (16×4) uint64_t version; // для cache invalidation } Layout; typedef struct LayoutRegistry { Layout *layouts; // dynamic array int nb_layouts; pthread_rwlock_t lock; // редко write, часто read uint64_t global_version; } LayoutRegistry; ``` **Layout registry — global** (см. §9): один process, один registry — переиспользуется между filter-instances. `set_layout name=X` filter ищет в registry, не клонирует — берёт pointer (read-locked). --- ## 5. Overlay system ### 5.1 7 primitive types ```python # domain/overlay.py (pydantic discriminated union) class OverlayBase(BaseModel): id: str # уникальный, для replace/delete instance_id: str | None = None # None = broadcast all instances cell_id: int | None = None # None = на canvas, int = относительно cell z_index: int = 100 alpha: float = 1.0 # 0..1 ttl_ms: int | None = None # auto-delete после privacy_tag: str | None = None # для privacy_profile filtering visible: bool = True class RectOverlay(OverlayBase): type: Literal["rect"] = "rect" x: float; y: float; w: float; h: float # normalized color: str # #RRGGBBAA stroke_width: int = 0 # 0 = filled rounded_corners: int = 0 # px class TextOverlay(OverlayBase): type: Literal["text"] = "text" text: str x: float; y: float font: str = "DejaVuSans" size: int = 16 color: str = "#FFFFFF" background: str | None = None # text bg box anchor: Literal["top-left","center","bottom-right",...] = "top-left" class IconOverlay(OverlayBase): type: Literal["icon"] = "icon" icon: str # name из preloaded sprite sheet x: float; y: float size: int = 32 tint: str | None = None class ImageOverlay(OverlayBase): type: Literal["image"] = "image" source: str # path или URL (preload по first-use) x: float; y: float; w: float; h: float class DimOverlay(OverlayBase): type: Literal["dim"] = "dim" x: float; y: float; w: float; h: float factor: float = 0.5 # 0..1, multiplier для luma class GraphOverlay(OverlayBase): type: Literal["graph"] = "graph" kind: Literal["line","bar","histogram","sparkline"] x: float; y: float; w: float; h: float data_source: str # symbolic ID — кто-то push'ает данные refresh_rate_hz: float = 2.0 style: dict # passthrough в renderer class ChatOverlay(OverlayBase): type: Literal["chat"] = "chat" x: float; y: float; w: float; h: float max_lines: int = 5 line_ttl_ms: int = 8000 font_size: int = 14 # сообщения push'аются отдельно через add_chat_message Overlay = Annotated[ RectOverlay | TextOverlay | IconOverlay | ImageOverlay | DimOverlay | GraphOverlay | ChatOverlay, Field(discriminator="type") ] ``` ### 5.2 Где живёт overlay state - **Declarative state (active overlays):** в controller — `OverlayStore` (instance_id → list[Overlay]). - **GPU texture cache:** в filter — `GpuOverlayCache` (overlay_id → CUDA texture/array). Lazy-загружается при first render. - **Live data feed для graphs/chats:** controller pumps данные → renders на CPU (cairo) с rate-limit → upload CUDA texture → notify filter. ### 5.3 Как overlay добирается до filter **Два канала** в зависимости от типа payload: 1. **Mutable lightweight overlays (rect/text/icon/dim)** — через `process_command`: ``` set_overlay id=event_42 json={"type":"rect","cell_id":0,"x":0.1,...} ``` Filter parse JSON, кэширует, рендерит. Update — same `set_overlay` с тем же id. Delete — `clear_overlay id=event_42`. 2. **Heavy overlays (image/graph/chat — нужны RGBA pixel buffer'ы)** — через **AVFrame side data**: - Controller рендерит на CPU (cairo), upload в shared GPU memory (cuMemAlloc'ed, IPC handle переиспользуется через cuframes-style channel). - Side data type: `AV_FRAME_DATA_USER + offset` (custom), payload содержит overlay_id + CUDA IPC handle к texture + dirty_flag. - Filter dereference handle (один раз — закэшировано), при `dirty_flag=true` re-upload. **Альтернатива для side data** — простой Unix socket controller↔filter с протоколом "вот тебе IPC handle к новой текстуре для overlay X". Менее coupled с FFmpeg AVFrame, проще debugging. Рекомендую **второй вариант** для phase 1, AVFrame side data — phase 2 (когда захочется detection bboxes от upstream filter'а напрямую через side data из `vf_detect` или Frigate-bridge). ### 5.4 GPU rendering pipeline для graphs/chats ``` controller event-loop sidecar→filter channel filter (GPU) ───────────────────── ────────────────────── ──────────── event → update_graph_data(g, value) ↓ graph_renderer.queue(g) ↓ [rate-limited — refresh_hz] cairo.render(g.data) → RGBA buf ↓ cuda_upload(buf) → device texture side-channel: ↓ texture_updated(overlay=g, handle=H, version=V) ─────────────────────────────────────────► ↓ gpu_cache[g] = H mark_dirty(g) ↓ (next frame) blit с alpha ``` Critical detail: **chat и graph живут вне frame timeline**. Они обновляются по event-rate (chat — сообщение пришло, graph — секундный tick). На каждом video frame filter просто blit'ит latest cached texture. CPU не делает работу на каждый кадр. --- ## 6. Side data + overlay producers **Кто кладёт overlay payload:** | Source | Path | Example | |---|---|---| | Controller (default) | HTTP/MQTT/ZMQ → command router → `set_overlay` | UI клик «дать рамку cam3» | | Frigate events bridge | `frigate_bridge.py` subscribes MQTT `frigate/+/events` → translate → `set_overlay` | bbox на detection | | External script | curl POST /overlay/add | crontab "show weather widget at 8am" | | HA automation | MQTT publish | "при звонке домофона show overlay 'door'" | | Upstream filter (future) | AVFrame side data `AV_FRAME_DATA_DETECTION_BBOXES` | если detection в FFmpeg graph | **Frigate bridge — конкретный example:** ```python # adapters/frigate_bridge.py async def on_frigate_event(payload): ev = json.loads(payload) if ev["type"] == "new": bbox = ev["after"]["box"] await commands.set_overlay(RectOverlay( id=f"frigate_{ev['after']['id']}", cell_id=camera_to_cell(ev["after"]["camera"]), x=bbox[0]/ev["after"]["frame_width"], y=bbox[1]/ev["after"]["frame_height"], w=bbox[2]/ev["after"]["frame_width"], h=bbox[3]/ev["after"]["frame_height"], color="#FF0000A0", stroke_width=3, ttl_ms=2000, privacy_tag="frigate_bbox", )) if ev["after"].get("plate"): await commands.set_overlay(TextOverlay( id=f"frigate_lpr_{ev['after']['id']}", cell_id=camera_to_cell(ev["after"]["camera"]), text=ev["after"]["plate"]["text"], x=bbox[0]/W, y=bbox[3]/H + 0.02, size=18, color="#FFFFFF", background="#000000A0", ttl_ms=3000, privacy_tag="lpr_text", # ← privacy_profile=public скроет )) ``` --- ## 7. Control plane protocols ### 7.1 ZeroMQ flow **Commands IN:** - FFmpeg `zmq` filter биндится на `tcp://127.0.0.1:5555` (REP socket). - Controller — REQ client → filter target → command. - Формат FFmpeg zmq filter: ` ` где target = `instance_id` (filter resolve'ит через `instance_id` option, см. §2.1). **Events OUT:** - Controller bind'ит PUB socket `tcp://0.0.0.0:5556`. - Topic prefix = `event//`. Subscribers filter'ят. ### 7.2 MQTT topic taxonomy ``` cuda_grid/cmd//layout/set ← set_layout cuda_grid/cmd//layout/create ← новое определение cuda_grid/cmd//cell//bind ← bind_cell cuda_grid/cmd//overlay/set ← set_overlay (payload=Overlay JSON) cuda_grid/cmd//overlay//clear ← clear cuda_grid/cmd//privacy/set ← set_privacy_profile cuda_grid/state//layout ← retained: current layout cuda_grid/state//cells ← retained: cell→camera mapping cuda_grid/state//overlays/ ← retained per overlay cuda_grid/state//fps ← stats, periodic cuda_grid/event//layout_switched ← non-retained, fact-of-event cuda_grid/event//cell_camera_changed cuda_grid/event//fps_drop cuda_grid/event//overlay_added cuda_grid/event//overlay_expired cuda_grid/event//inputs_starved cuda_grid/event//cell_stale cuda_grid/event/audio/ducked cuda_grid/event/audio/restored homeassistant/select/cuda_grid__layout/config ← HA discovery homeassistant/sensor/cuda_grid__fps/config homeassistant/binary_sensor/cuda_grid__input_alive/config ``` **HA Discovery — конкретный пример (layout selector):** ```json { "name": "Living Room TV Layout", "unique_id": "cuda_grid_tv1_layout", "command_topic": "cuda_grid/cmd/tv1/layout/set", "state_topic": "cuda_grid/state/tv1/layout", "options": ["single","quad","nine_grid","main_plus_preview","custom_3x4"], "device": { "identifiers": ["cuda_grid_tv1"], "name": "CUDA Grid: tv1", "manufacturer": "gx/vf-cuda-grid", "sw_version": "" } } ``` ### 7.3 HTTP REST API | Endpoint | Method | Body | Purpose | |---|---|---|---| | `/instances` | GET | — | список filter-instances + текущее state | | `/instance/{id}` | GET | — | detailed state | | `/instance/{id}/layout` | POST | `{name, transition?}` | set_layout | | `/instance/{id}/cell/{n}/bind` | POST | `{camera_id}` | bind_cell | | `/instance/{id}/privacy` | POST | `{profile}` | privacy switch | | `/layouts` | GET | — | список layout definitions | | `/layouts` | POST | Layout JSON | create/update layout | | `/layouts/{name}` | DELETE | — | удалить | | `/instance/{id}/overlays` | GET | — | active overlays | | `/instance/{id}/overlays` | POST | Overlay JSON | add/replace | | `/instance/{id}/overlays/{oid}` | DELETE | — | remove | | `/instance/{id}/chat/{oid}/message` | POST | `{text, color?}` | push в chat overlay | | `/audio/duck` | POST | `{source, duration_ms, ratio}` | manual ducking | | `/events` | GET (SSE) | — | streaming events | | `/health` | GET | — | liveness | | `/metrics` | GET | — | Prometheus | OpenAPI schema публикуется автоматически (FastAPI). Endpoints версионируются `/v1/...` с самого начала. ### 7.4 Conflict resolution **Источники конфликтуют** (HA шлёт `layout=quad`, MQTT-rule шлёт `layout=nine_grid` в 50ms интервале). Политика: 1. **Single command router** — все commands из всех протоколов кладутся в один asyncio.Queue (или Go channel). Сериализация естественная. 2. **Idempotency key** — каждая команда имеет (optional) `cmd_id` (UUID). Дубликаты dropped. 3. **Per-source priority** (конфигурируемый): ``` priority: ha_automation: 100 mqtt: 80 http: 50 zmq: 50 frigate: 30 ``` В пределах **same-tick window** (50ms) старшая priority побеждает; младшая — discarded + emit event `command_overridden{by=ha_automation}`. 4. **Locking through `set_priority_lock`** — manual UI lock на N секунд: `POST /instance/tv1/lock {duration=60s}` — только источник с лок-token может менять instance. 5. **Last-write-wins** для overlays с одинаковым `id` (естественно через replace-semantics). ### 7.5 Event taxonomy (publishes наружу) | Event | When | Payload | |---|---|---| | `layout_switched` | после применения | `{from, to, reason, source}` | | `cell_camera_changed` | bind_cell | `{cell, prev_camera, new_camera}` | | `overlay_added` | set_overlay (новый id) | `{id, type, instance, cell?}` | | `overlay_updated` | set_overlay (existing) | `{id, type}` | | `overlay_expired` | TTL fired | `{id, reason: "ttl"\|"manual"}` | | `fps_drop` | output_fps < threshold | `{instance, current, expected, since_ms}` | | `inputs_starved` | все inputs stale | `{instance, last_seen_per_input}` | | `cell_stale` | один input stale | `{instance, cell, camera, age_ms}` | | `audio_ducked` | ducking active | `{rule, source, duration_ms}` | | `audio_restored` | ducking ended | `{rule}` | | `command_overridden` | conflict-resolution dropped | `{cmd, source, by}` | | `controller_started` | startup | `{version, instances}` | Все события публикуются в: - ZeroMQ PUB `tcp://0.0.0.0:5556` topic `event..` - MQTT `cuda_grid/event/...` - HTTP `/events` SSE --- ## 8. Audio orchestration **Architectural stance:** vf_cuda_grid **сам аудио не трогает**. Аудио — стандартные FFmpeg filters (`amix`, `sidechaincompress`, `volume`), controller координирует их **через те же `process_command`** что и video. **State machine — пример "домофон":** ``` states: idle: music plays @ vol=1.0, no doorbell screen ringing: duck music (vol=0.3), switch tv1 to main_plus_preview с camera_door как main, show icon "doorbell" на canvas, audio из cam_door amplified ringing_acked: keep grid, music back to 0.8, доорбель квитирован cooldown: 24s timer, после — restore music+layout events triggering: on(mqtt:doorbell/ringing): from {idle,cooldown} → ringing on(mqtt:doorbell/answered): from ringing → ringing_acked on(timer 30s): from ringing → ringing_acked on(timer 24s in cooldown): → idle actions per state: ringing: - ffmpeg.cmd: "vol_music volume 0.3" - ffmpeg.cmd: "sc_music threshold 0.05" # sidechain compression bumps - ffmpeg.cmd: "tv1_grid set_layout main_plus_preview" - ffmpeg.cmd: "tv1_grid bind_cell cell=0 camera=4" # cam_door - ffmpeg.cmd: "tv1_grid set_overlay id=doorbell_icon json=..." - publish event audio_ducked{rule=doorbell} - publish event layout_switched ``` **Rule engine реализация:** YAML-конфиг с состояниями и triggers, parse в `transitions` library (pure-Python state-machine). Простой, тестируемый, не over-engineered. ```yaml # orchestration_rules.yaml rules: doorbell: triggers: - on: mqtt topic: doorbell/state value: ringing states: ringing: enter: - ffmpeg_cmd: { target: vol_music, cmd: volume, arg: "0.3" } - ffmpeg_cmd: { target: tv1_grid, cmd: set_layout, arg: "main_plus_preview" } - overlay: { id: doorbell, type: icon, icon: bell, x: 0.45, y: 0.05, size: 64 } exit: - ffmpeg_cmd: { target: vol_music, cmd: volume, arg: "1.0" } - clear_overlay: doorbell timeout: 30s → ringing_acked ringing_acked: timeout: 24s → idle ``` --- ## 9. Multi-instance behaviour ### 9.1 Shared inputs Через FFmpeg native `split` filter (см. §2.1). Каждый `cuda_grid` instance получает свою копию pointer'а на CUDA frame (refcount++). Bandwidth GPU↔GPU = 0 (только ref-count). ### 9.2 Layout registry — global или per-instance **Рекомендация — hybrid** (как и в требованиях): - **Global LayoutRegistry** в FFmpeg process — одна instance per FFmpeg process, owned первым `cuda_grid` filter который ini'ится. Subsequent instances reference тот же registry. - **Active layout pointer + cell_to_input mapping + overlays** — **per-instance** state. - **Privacy profile** — per-instance. **Why global registry:** layout definitions — read-mostly (создаются редко, читаются часто). Sharing экономит память (один nine_grid с 9 cell'ами — 9×structures ×3 instances = 27 структур vs 9 with shared). **Реализация sharing:** при init filter проверяет process-wide singleton (atomic pointer + ref-count) и либо создаёт, либо подключается. При уничтожении последнего — освобождает. Когда controller hot-reload'ит registry — он шлёт `reload_registry` команду которая broadcast'ится в зарегистрированные instances; они apply'ят (read-write lock на registry, atomic version-bump). ### 9.3 AVFrame ref-counting Стандартный FFmpeg механизм. После `split` каждый instance получает `AVFrame*` с увеличенным refcount. `av_frame_free()` уменьшает; при 0 → освобождение в frame pool. У CUDA frames — pool reuse (через `hw_frames_ctx`), физическая GPU память не deallocate'ится. ### 9.4 Independent output timing Каждый instance имеет свой `target_fps` option. Pull-based: filter framework сам вызывает `request_frame` на каждый output. Если tv1 = 25fps, public = 30fps — два независимых rates, inputs shared. --- ## 10. Library choice для controller ### Сравнение | Aspect | Python (FastAPI + asyncio) | Go (chi/fiber + nats) | Rust (axum + tokio) | |---|---|---|---| | Ecosystem MQTT/ZMQ | `paho-mqtt`, `pyzmq`, `aiomqtt` — все proven | `eclipse/paho.mqtt.golang`, `go-zeromq/zmq4` — proven | `rumqttc`, `zmq` — proven, но менее mature | | HA Discovery integration | Direct МQTT — нет специфик | Same | Same | | Audio-orchestration rule engine | `transitions` library — отличный | `looplab/fsm` — норм | crate `statig` — норм, меньше docs | | Cairo/skia для graph rendering | `pycairo`/`cairocffi` — solid | `gioui.org/cairo` экзотика, либо CGO | `cairo-rs` — ok, но больше pain | | FastAPI + Pydantic схемы | First-class, OpenAPI free | manual schemas или `huma` (новее) | `utoipa` + axum-extra — работает | | Developer velocity | high — Python для control plane проверенный паттерн | medium — больше boilerplate | low для iterate fast | | Runtime performance | достаточно (control plane, не hot path) | хорошо | отлично | | Memory footprint | ~30-50MB | ~10-20MB | ~5-10MB | | Familiarity в нашей экосистеме | high (paddleocr, frigate-cuframes — все Python) | low | low | | Single static binary distribution | ✗ (Python deps) | ✓ | ✓ | ### Рекомендация: **Python (FastAPI + asyncio + pydantic + paho-mqtt + pyzmq + transitions)** **Зачем:** 1. **Hot path не controller-side** — pixel-pushing в filter (C/CUDA). Controller — coordination layer, для него Python ровно. Прирост latency MQTT→ZMQ командой 1-3ms на Python — несущественно при cycle time видео 40ms. 2. **Cairo rendering для graphs/chats** — Python `pycairo` зрелый. В Go это pain. 3. **Frigate community — Python-native**. Bridge plugins, examples, doc-set резко легче adopt'ить. 4. **Iteration speed важнее runtime**. Audio rules, overlay logic, conflict resolution — это бизнес-логика которая меняется. Python переписывается быстро. 5. **Memory footprint 30-50MB** для control plane — не существенно (Frigate сам жрёт ~500MB, NVDEC — гигабайты VRAM). **Стек deps:** ``` fastapi # HTTP REST + auto OpenAPI uvicorn # ASGI server pydantic >=2 # domain models, validation pyzmq # FFmpeg zmq client + events PUB aiomqtt # asyncio MQTT (тонкая обёртка над paho) paho-mqtt # fallback для HA discovery если нужен transitions # state machines для audio orchestration pycairo # graph/chat rendering CPU-side sse-starlette # /events SSE prometheus-client # /metrics structlog # structured logs typer # CLI pyyaml # rule configs ``` **Distribution:** Docker image + PyPI package (`cuda-grid-controller`). systemd unit в `deploy/`. **Что бы заставило перейти на Go:** если стало бы 100+ filter-instances и controller стал bottleneck. Сейчас realistic 1-10 instances per host. Будет проблема — extract controller (он чистый по domain) и rewrite в Go без переписки filter. Reversible. --- ## 11. Phases of implementation ### PR-1 — MVP filter (fixed quad) **Scope:** - `filter/vf_cuda_grid.c` + minimal CUDA kernels (NV12 region memcpy only) - Hardcoded quad layout (2×2), 4 inputs, fixed output 1920×1080 - No scaling — assumes inputs already at 960×540 - No overlays, no borders, no labels - `process_command` skeleton (NOP) - FFmpeg patch file для n7.1 - CLI usable: `ffmpeg -i cuframes://... [×4] -filter_complex cuda_grid=inputs=4 -c:v h264_nvenc out.mp4` - Unit tests: kernel-level pixel-perfect tests against known fixtures - Docker integration test **Deliverable:** working FFmpeg binary с фильтром. Demo: 4 cam → quad mp4. ### PR-2 — Dynamic layouts + per-cell scaling **Scope:** - LayoutRegistry, JSON layout loading (`layout_file=` option) - Cell scaling — NPP integration (NV12 bilinear) - Borders + background fill + `fit` modes (cover/contain/stretch) - `process_command`: `set_layout`, `bind_cell`, `swap_cells` - Cell-camera binding state - All 9 predefined layouts (соответствующих текущему `grids.json`) - Cross-fade transition (linear blend) — opt-in через command - Lock-step input policy: `keep_last` + `stale_timeout_ms` **Deliverable:** layout switching из CLI/FFmpeg `sendcmd`. Visual transitions. ### PR-3 — Controller skeleton **Scope:** - `controller/` package: FastAPI app, MQTT subscribe/publish, ZMQ client (commands)+ pub (events) - HA Discovery payloads для layout select + fps sensor + input_alive binary_sensor - Command Router + Conflict Resolver (single-source-of-truth queue) - State Store (in-memory + JSON snapshot) - HTTP endpoints для `/layouts`, `/instance/.../layout`, `/instance/.../cell/.../bind` - `/events` SSE - ZMQ PUB events outbound - Filter publishes events back: добавить в filter side `zmq_publish_event` (TX socket в parallel к command RX) — простая JSON-line на тот же socket с тегом - Docker compose: filter + controller + mosquitto + HA mock - Integration test: HTTP POST /layout/set → MQTT state retained → HA reflects **Deliverable:** Production-grade control plane без overlay'ев. Frigate-bridge `examples/`. ### PR-4 — Overlays: rect/text/icon basics **Scope:** - `OverlayStore` in controller (in-mem, MQTT retained sync) - CUDA kernels: alpha-blit RGBA texture → NV12, glyph blit - Text rendering: **CPU-precomputed glyph atlas** (cairo render glyphs at startup → CUDA texture). Не Pango на каждый кадр. - Icon rendering: preloaded sprite sheet (configured iconpack) - `process_command`: `set_overlay`, `clear_overlay` (JSON через `arg`) - TTL handling (controller-side timers) - Privacy profile filtering - Frigate bridge sample: detection bbox → rect+text overlay с TTL **Deliverable:** Frigate detections visible в mosaic с privacy controls. ### PR-5 — Image/dim/graph/chat overlays **Scope:** - Image overlay: lazy-load по path, upload в CUDA texture cache - Dim overlay: simple luma-multiply kernel - Graph overlay: cairo CPU rendering, IPC-handle channel controller↔filter - Chat overlay: rolling message list, cairo рендеринг + fade-out per line - Side-channel protocol: Unix socket для texture handles (см. §5.3) - HTTP endpoint `/instance/.../chat/.../message` - HA Discovery: добавить `sensor` для текущей graph value - Performance bench: 8 overlays @ 25fps на RTX 5090 — target <0.5ms compose time **Deliverable:** Полный overlay-роудмап. Weather widget + LPR scroll + motion timeline в production. ### PR-6 — Audio orchestration **Scope:** - `orchestration/audio_orchestrator.py` — state machine engine (transitions library) - YAML rule configs (`orchestration_rules.yaml`) - FFmpeg ZMQ client расширяется на audio targets (`amix`, `volume`, `sidechaincompress`) - 3 example rules: doorbell, baby-cry detection, motion-night - `audio_ducked` / `audio_restored` events - HTTP `/audio/duck` для manual triggers - E2E test: simulated MQTT doorbell event → music ducks + grid switches + overlay shown + restore **Deliverable:** Полная control plane platform. Closes [gx/cctv#22](https://git.goldix.org/gx/cctv/issues/22) Phase 4. --- ## 12. Risks / open questions | # | Risk | Mitigation / current stance | |---|---|---| | R1 | FFmpeg `process_command` arg — single string; сложные overlays нуждаются в JSON. Limit на длину? | FFmpeg AVOption parsing допускает строки до ~4KB. Большие payloads — через side-channel (см. §5.3). Доc'нём ограничение в filter README. | | R2 | Upstream FFmpeg: примут ли `vf_cuda_grid`? | Не ставим upstream на critical path. Доходим до v0.3 (mature) — submit PR. До этого — out-of-tree patch (как cuframes уже делает с `cuframesdec.c`). | | R3 | NPP licensing — proprietary, CUDA-bundled | NPP идёт с CUDA Toolkit, который и так нужен. Не нарушает LGPL фильтра (filter — LGPL, NPP — dynamic link). | | R4 | Glyph atlas: какие шрифты shipped? | DejaVu (free) preloaded; пользователь может override через config. Юникод — full BMP плюс CJK по запросу (lazy). | | R5 | Multi-GPU не поддерживается v0.1 (consistent с cuframes) | Документируем. Multi-GPU = v0.3+. | | R6 | Lock-step при сильно разных fps inputs (cam@10fps + cam@30fps) | Tolerance window per-input configurable. Slow camera gets `keep_last`. Документируем как known constraint. | | R7 | Filter perf на 16-cell layout @ 4K | Бенчмарк в PR-5. Если не укладывается в frame budget — на тяжёлых composition'ах рекомендуем output 1080p (downscale 4K cameras уже в `cuda_grid`). | | R8 | Cuframes-IPC + filter sharing — interaction с frame pool lifecycle | Проверить что `hw_frames_ctx` от cuframes-demuxer'а correctly ref-share'ится через `split` → filter. Risk integration test обнаружит. | | R9 | controller restart drops live overlay state | MQTT retained для declared overlays + JSON snapshot для local cache. Restart восстанавливает из обоих. Ephemeral TTL overlays — теряются, что acceptable. | | R10 | Conflict-resolution priority — UX-проблема (юзер не понимает почему его команда no-op) | Каждый dropped command emit'ит event `command_overridden{by=...}` — UI/log виден. Доc'нём priority defaults. | | R11 | text/glyph rendering CJK/RTL | Phase 1 — LTR Latin/Cyrillic. CJK/RTL — open question, в v0.4 (после Phase 6). | | R12 | Cross-fade при разных aspect/size между layouts | Cross-fade — pixel-blend двух уже-композированных canvas, размеры одинаковые (output_size фиксирован per instance). Безопасно. | | R13 | Audio orchestration — race conditions между MQTT events | Single asyncio queue + state machine = serialized. State machine library `transitions` thread-safe. | **Open questions для обсуждения с командой:** 1. **CUDA min version.** Cuframes v0.1 нацелен на CUDA 12+. Sticking с этим или 11.8 для wider compat? **Stance: CUDA 12+, явно declared.** 2. **MQTT vs NATS** для events? MQTT — universal (HA, Frigate); NATS — perf. **Stance: MQTT primary (ecosystem fit), NATS не нужен сейчас.** 3. **Layout DSL — JSON vs YAML?** Текущий cctv-processor — JSON. **Stance: JSON для machine-generated/REST-API, YAML для human-edited rules. Schema один, два serialization frontend'а через pydantic.** 4. **Glyph atlas vs FreeType-on-CUDA?** **Stance: атлас (proven, simple). FreeType-on-CUDA — слишком экзотика для v0.1.** 5. **License: LGPL vs MIT?** Filter inherits LGPL от FFmpeg (он LGPL). Controller — отдельная codebase, MIT. **Stance: dual — `filter/` LGPL-2.1+, `controller/` MIT.** Чётко documented в LICENSE и subdir LICENSE files. 6. **Frigate camera ID space.** Frigate camera names — strings, наш `camera_id` — int. **Stance: controller хранит mapping name↔index, abstraction layer.** --- ## 13. Migration path для cctv-processor **Текущий cctv-processor pipeline:** ``` cuframes → cv::Mat (GPU→host download) → GridComposer (CPU OpenCV) → swscale → host→GPU upload (для Frigate detect) → h264_nvenc → RTSP-out ``` **После миграции на vf_cuda_grid:** ``` cuframes://cam[1..4] ─► ┌─ split=2 ─► cuda_grid=instance_id=tv (layout=main_plus_preview) ─► h264_nvenc ─► RTSP-tv └─ ─► (preview-only, не масштабируется до Frigate) ``` `cctv-processor` сам **исчезает как frame-processor**. Что остаётся: - `GridManager` business logic (auto-switching по motion, priority switching, history) — **перевозится в controller** как orchestration rules (Python). - `SnapshotManager` — становится отдельным маленьким FFmpeg pipe который читает same output (HD-snap каждые 5 мин). - `EventSystem` internal bus — упраздняется, заменяется на ZMQ events. - REST API endpoints `cctv-processor` — становятся endpoints в `cuda-grid-controller` (миграция URL-схемы 1:1 + redirects). - `cameras.json`, `grids.json`, `analytics.json` — **остаются**, читаются controller'ом + translate в наш `layouts.json` schema (compat-конвертер). ### Migration steps 1. **PR-A в cctv repo:** добавить compat layer — cctv-processor может вытаскивать composed frames через `cuframes_packets://` от vf_cuda_grid output. Pure shadow-mode, обе системы работают параллельно, output идентичен (compare-test). 2. **PR-B в localhost-infra:** docker-compose добавляет `cuda-grid-controller` + `ffmpeg-mosaic` containers рядом с cctv-processor. 3. **Production cutover:** TV stream switch из cctv-processor RTSP → ffmpeg-mosaic RTSP. cctv-processor остаётся для snapshots + analytics (короткое время). 4. **PR-C:** snapshots переезжают на отдельный slim FFmpeg pipe. 5. **PR-D:** analytics (motion events) от Frigate напрямую в controller (Frigate bridge). cctv-processor decommissioned. Closes [gx/cctv#22](https://git.goldix.org/gx/cctv/issues/22) Phase 4. ### Compat-конвертер `grids.json` → `layouts.json` Простой Python скрипт в `examples/cctv-processor-migrate/`. Field mapping: | old `grids.json` | new `layouts.json` | |---|---| | `grid_templates..cells[].camera_id` | runtime state (per-instance cell binding), извлекается отдельно | | `cells[].x/y/width/height` | `cells[].x/y/w/h` (то же 0..1) | | `border/label` | в overlay-template или defaults | | `motion_indication` | overlay-template `motion_indicator` (rect с alpha, controller добавляет на motion event) | | `transition_settings.duration_ms` | `set_layout_transition` arg | | `default_grid` | `instances..default_layout` | --- ## 14. Overlap с `cctv-processor` MQTT plugin (gx/cctv#24) **TL;DR — это один и тот же controller.** Не два. ### Анализ overlap | Feature | cctv #24 MQTT plugin | cuda-grid-controller | |---|---|---| | MQTT subscribe для layout switch commands | ✓ | ✓ | | HA Discovery для layout selector | ✓ | ✓ | | MQTT state publishing | ✓ | ✓ | | Events publishing наружу | ✓ | ✓ | | Internal event bus integration | EventSystem (C++ in-process) | ZMQ events | | HTTP REST | partial | ✓ | | Audio orchestration | — | ✓ | | Overlay control | — | ✓ | Overlap составляет 60-70%. И — самое важное — cctv-processor мигрирует на vf_cuda_grid (см. §13). После миграции cctv-processor больше не нуждается в собственном MQTT plugin: его функция уже в `cuda-grid-controller`. ### Рекомендация 1. **`gx/cctv#24` — не строить standalone.** Превращается в эпик "Adopt cuda-grid-controller MQTT/HA-Discovery для cctv-processor mosaic management" с зависимостью от vf-cuda-grid PR-3. 2. **cuda-grid-controller — становится канонический control plane** для grid-style video composition в нашей экосистеме. Дизайнится с учётом, что в перспективе может управлять не только vf_cuda_grid, но и другими grid-producer'ами (любая FFmpeg pipeline со standard ZMQ command-target'ами). Это **уже верный design** — controller talks к filter через generic ZMQ протокол, не специфичный для cuda_grid. 3. **Extract path:** когда controller дозреет (≥ Phase 3) и появится 2-й consumer (например GStreamer pipeline в другом продукте) — extract в отдельный repo `gx/grid-controller`. До этого живёт в `gx/vf-cuda-grid/controller/`. Reversible move без рerпройдiging API. ### Что меняется в cctv#24 Issue переформулируется: > ~~"Add MQTT plugin to cctv-processor"~~ → > **"Migrate cctv-processor mosaic management to `cuda-grid-controller` (depends on `gx/vf-cuda-grid` PR-3)"** Acceptance: cctv-processor либо decommissioned (§13 happy path), либо его mosaic-control logic вызывает HTTP API нашего controller (compatibility-mode). --- ## Next steps (ordered, ready to start) 1. Open issue `gx/vf-cuda-grid#1` ("Design accepted") — paste этот документ как issue body. 2. Create repo `gx/vf-cuda-grid` с скелетом (README, ROADMAP, LICENSE-dual, empty `filter/`, `controller/`, `schema/`, `examples/`, `docs/`). 3. Update `gx/cctv#24` — переформулировать как "depends on vf-cuda-grid PR-3", закрыть как standalone scope. 4. Update `gx/cuframes` ROADMAP "Future ideas → `vf_cuda_grid`" — пометить как **moved to** `gx/vf-cuda-grid` repo. 5. Start PR-1 (MVP filter, fixed quad) — отдельный issue/branch. --- **Relevant files reviewed for this design:** - `/home/claude/projects/cctv/cpp/apps/cctv-processor/include/grid/GridComposer.h` - `/home/claude/projects/cctv/cpp/apps/cctv-processor/include/grid/GridManager.h` - `/home/claude/projects/cctv/cpp/apps/cctv-processor/config/grids.json` - `/home/claude/projects/cuframes/ROADMAP.md` (section "Future ideas → `vf_cuda_grid`") - `/home/claude/projects/cuframes/README.md` - `/home/claude/projects/cuframes/filter/cuframesdec.c` (existing out-of-tree FFmpeg patch pattern — model для нашего)