Phase 11b B-E: ООП-гипотеза проверена end-to-end

Что в этом коммите:

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:43:18 +01:00
parent f1c79eabde
commit beb8e1baa0
22 changed files with 1644 additions and 3 deletions
@@ -0,0 +1,25 @@
/* BlankCell — пустая cell (Phase 11b).
*
* Используется для "идущего идле" слота без камеры — рисует чёрный rect
* на месте cell. Альтернативно может быть placeholder с надписью "NO SIGNAL"
* через LabelDecoration.
*/
#ifndef CUFRAMES_COMPOSER_CPP_BLANK_CELL_HPP
#define CUFRAMES_COMPOSER_CPP_BLANK_CELL_HPP
#include "cell.hpp"
namespace cfc {
class BlankCell : public Cell {
public:
explicit BlankCell(const Rect& geom) : Cell(geom) {}
protected:
void draw_content(CUstream stream, NV12Ref& dst) override;
};
} // namespace cfc
#endif /* CUFRAMES_COMPOSER_CPP_BLANK_CELL_HPP */
@@ -0,0 +1,39 @@
/* BorderDecoration — рамка вокруг cell (Phase 11b).
*
* 4 узких прямоугольника (top/bottom/left/right) через cfc_cugrid_fill_nv12.
* Полезна для подсветки main cell в layout'е или recording-indicator'ов.
*
* Лицензия: LGPL-2.1+
*/
#ifndef CUFRAMES_COMPOSER_CPP_BORDER_DECORATION_HPP
#define CUFRAMES_COMPOSER_CPP_BORDER_DECORATION_HPP
#include "decoration.hpp"
namespace cfc {
struct BorderStyle {
int thickness = 3;
int color_y = 210, color_u = 50, color_v = 100; /* BT.709 limited */
int alpha = 240;
bool visible = true;
};
class BorderDecoration : public Decoration {
public:
explicit BorderDecoration(const BorderStyle& style) : style_(style) {}
~BorderDecoration() override = default;
void set_visible(bool v) noexcept { style_.visible = v; }
void set_style(const BorderStyle& s) noexcept { style_ = s; }
void draw(CUstream stream, NV12Ref& dst, const Rect& parent_rect) override;
private:
BorderStyle style_;
};
} // namespace cfc
#endif /* CUFRAMES_COMPOSER_CPP_BORDER_DECORATION_HPP */
@@ -0,0 +1,39 @@
/* CameraCell — рисует кадр из cuframes-источника в свой Rect (Phase 11b).
*
* Cell держит non-owning указатель на cfc_source_t (живёт в SourcePool
* композитора). На каждом draw_content():
* 1. cfc_source_get_latest — snapshot последнего кадра в VRAM
* 2. если ACTIVE/STALE — cfc_cugrid_resize_nv12 в свою geom_
* 3. если DEAD/CONNECTING — пропуск (cell остаётся blacked out)
*
* Decorations (label, border) рисуются в Cell::draw() поверх content'а.
*
* Лицензия: LGPL-2.1+
*/
#ifndef CUFRAMES_COMPOSER_CPP_CAMERA_CELL_HPP
#define CUFRAMES_COMPOSER_CPP_CAMERA_CELL_HPP
#include "../source.h"
#include "cell.hpp"
namespace cfc {
class CameraCell : public Cell {
public:
CameraCell(const Rect& geom, cfc_source_t* source)
: Cell(geom), source_(source) {}
void set_source(cfc_source_t* src) noexcept { source_ = src; }
cfc_source_t* source() const noexcept { return source_; }
protected:
void draw_content(CUstream stream, NV12Ref& dst) override;
private:
cfc_source_t* source_; /* non-owning — pool владеет */
};
} // namespace cfc
#endif /* CUFRAMES_COMPOSER_CPP_CAMERA_CELL_HPP */
+120
View File
@@ -0,0 +1,120 @@
/* Composer — оркестратор Phase 11b.
*
* Owns:
* - SourcePool (cuframes-источники + motion state)
* - vector<LayoutTemplate> (loaded from JSON или builtins)
* - Layout (текущее состояние cells)
* - OutputSurface (CudaBuffer для NV12 output)
*
* Compose loop (по кадру):
* 1. select_template_and_active() → (LayoutTemplate*, vector<PoolEntry*>)
* по правилам: motion_mode? motion-based best-fit : idle top-1 single
* 2. hysteresis: рост сразу, уменьшение — wait shrink_hysteresis_ms
* 3. если sig != committed → layout_.apply(template, active, W, H)
* 4. compose_clear() → output буфер чёрный
* 5. layout_.render(stream, NV12Ref)
*
* Public API экспортируется через composer_c_api.cpp с extern "C" для
* совместимости с control.c, grid_record.c, frigate_mqtt.c.
*
* Лицензия: LGPL-2.1+
*/
#ifndef CUFRAMES_COMPOSER_CPP_COMPOSER_HPP
#define CUFRAMES_COMPOSER_CPP_COMPOSER_HPP
#include "cuda_raii.hpp"
#include "layout.hpp"
#include "source_pool.hpp"
#include "template.hpp"
#include "types.hpp"
#include <cuda_runtime.h>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
namespace cfc {
struct ComposerConfig {
int width = 1920;
int height = 1080;
int cuda_device = 0;
int bg_y = 16, bg_u = 128, bg_v = 128;
/* Motion-mode параметры. */
int motion_ttl_ms = 45000;
int shrink_hysteresis_ms = 3000;
/* Templates JSON path (empty → built-in). */
std::string templates_path;
};
class Composer {
public:
explicit Composer(const ComposerConfig& cfg);
~Composer();
Composer(const Composer&) = delete;
Composer& operator=(const Composer&) = delete;
bool ok() const noexcept { return output_.ok(); }
SourcePool& pool() noexcept { return pool_; }
const SourcePool& pool() const noexcept { return pool_; }
/* Motion mode + relayout policy. */
void set_motion_mode(bool on, int ttl_ms = 0);
bool motion_mode() const noexcept { return motion_mode_; }
/* Загрузить templates из JSON. Возвращает количество, либо <0. */
int load_templates(const std::string& path);
/* Перейти на named template (только если motion_mode == false). */
bool set_layout(const std::string& name);
const std::string& current_layout_name() const noexcept {
return layout_.name();
}
int templates_count() const noexcept {
return static_cast<int>(templates_.size());
}
const std::vector<LayoutTemplate>& templates() const noexcept {
return templates_;
}
/* Один кадр: relayout (если нужно) + clear + render.
* Возвращает NV12Ref на output (ptr действителен до следующего compose). */
NV12Ref compose_frame();
private:
/* Selection + hysteresis. */
const LayoutTemplate* pick_best_fit(int need) const;
std::vector<PoolEntry*> collect_active() const;
void maybe_relayout();
static std::string build_signature(const std::string& tpl_name,
const std::vector<PoolEntry*>& active);
ComposerConfig cfg_;
SourcePool pool_;
std::vector<LayoutTemplate> templates_;
Layout layout_;
/* Output NV12 буфер (VMM, zero-copy для NVENC). */
CudaBuffer output_;
int pitch_y_ = 0;
int pitch_uv_ = 0;
cudaStream_t stream_ = nullptr; /* default = 0 */
bool motion_mode_ = false;
std::int64_t committed_at_ms_ = 0;
std::int64_t pending_first_seen_ms_ = 0;
std::string committed_signature_;
std::string pending_signature_;
};
} // namespace cfc
#endif /* CUFRAMES_COMPOSER_CPP_COMPOSER_HPP */
@@ -0,0 +1,78 @@
/* LabelDecoration — текстовая подпись поверх cell (Phase 11b).
*
* Рендерит UTF-8 строку через FreeType в RGBA-атлас (создаётся один раз
* при ctor/set_text, держится в VRAM), затем на каждом draw() блитит
* атлас в указанный угол parent_rect через cfc_cugrid_blit_rgba_nv12.
*
* Корнер: top-left (cell.x + pad, cell.y + pad). Pad по умолчанию 8 px.
* Цвет, размер шрифта, alpha-множитель задаются в ctor.
*
* Лицензия: LGPL-2.1+
*/
#ifndef CUFRAMES_COMPOSER_CPP_LABEL_DECORATION_HPP
#define CUFRAMES_COMPOSER_CPP_LABEL_DECORATION_HPP
#include "decoration.hpp"
#include <ft2build.h>
#include FT_FREETYPE_H
#include <cuda.h>
#include <string>
namespace cfc {
struct LabelStyle {
std::string font_path = "/fonts/DejaVuSans-Bold.ttf";
int pixel_size = 22;
int r = 255, g = 220, b = 64; /* жёлто-оранжевый, читается на любом фоне */
int alpha = 255; /* множитель прозрачности 0..255 */
int pad = 8; /* отступ от угла cell */
/* visible можно переключать без перерендера атласа. */
bool visible = true;
};
class LabelDecoration : public Decoration {
public:
LabelDecoration(const std::string& text, const LabelStyle& style);
~LabelDecoration() override;
LabelDecoration(const LabelDecoration&) = delete;
LabelDecoration& operator=(const LabelDecoration&) = delete;
void set_visible(bool v) noexcept { style_.visible = v; }
bool visible() const noexcept { return style_.visible; }
/* Обновить текст (re-render atlas). Передвижение/visible — без re-render. */
void set_text(const std::string& text);
void draw(CUstream stream, NV12Ref& dst, const Rect& parent_rect) override;
private:
/* Pass1: измерить bbox строки + ascent для baseline'а. */
bool measure(int& w, int& h, int& ascent) const;
/* Pass2: отрисовать строку в RGBA-буфер CPU. */
void render_to_cpu(unsigned char* rgba, int w, int h, int ascent) const;
/* Rebuild VRAM atlas из текущей строки. */
bool rebuild_atlas();
/* FreeType state. */
FT_Library ft_lib_ = nullptr;
FT_Face face_ = nullptr;
/* Текст и стиль. */
std::string text_;
LabelStyle style_;
/* VRAM atlas. */
CUdeviceptr atlas_ = 0;
int atlas_w_ = 0;
int atlas_h_ = 0;
int atlas_pitch_ = 0;
};
} // namespace cfc
#endif /* CUFRAMES_COMPOSER_CPP_LABEL_DECORATION_HPP */
+57
View File
@@ -0,0 +1,57 @@
/* Layout — контейнер cells и оркестратор apply_template (Phase 11b).
*
* Layout::apply() принимает LayoutTemplate + список активных pool-entries
* (sorted by priority DESC) + output W×H. Создаёт нужные Cell-подклассы:
*
* CameraCell для каждой template-cell с role=CAMERA — берёт source
* по индексу из active list (active[0]=order 0, active[1]=order 1, ...)
* Если active меньше чем camera-cells — лишние cells = BlankCell.
* WidgetCell для template-cell с role=WIDGET — placeholder.
*
* Decorations добавляются здесь же:
* LabelDecoration "{key} prio={N}" в каждый CameraCell.
* LabelDecoration с именем widget'а в каждый WidgetCell.
* (Border, Badge — Phase 12+)
*
* Layout::render(stream, dst) — итеративно вызывает cell->draw().
*
* Лицензия: LGPL-2.1+
*/
#ifndef CUFRAMES_COMPOSER_CPP_LAYOUT_HPP
#define CUFRAMES_COMPOSER_CPP_LAYOUT_HPP
#include "cell.hpp"
#include "source_pool.hpp"
#include "template.hpp"
#include <memory>
#include <string>
#include <vector>
namespace cfc {
class Layout {
public:
Layout() = default;
/* Применить template — пересоздаёт cells + decorations.
* active_sorted — список pool-entries, уже отсортированный priority DESC. */
void apply(const LayoutTemplate& tpl,
const std::vector<PoolEntry*>& active_sorted,
int frame_w, int frame_h);
/* Прорисовать все cells в dst буфер. */
void render(CUstream stream, NV12Ref& dst);
const std::string& name() const noexcept { return current_name_; }
int cell_count() const noexcept { return static_cast<int>(cells_.size()); }
private:
std::vector<std::unique_ptr<Cell>> cells_;
std::string current_name_;
};
} // namespace cfc
#endif /* CUFRAMES_COMPOSER_CPP_LAYOUT_HPP */
@@ -0,0 +1,95 @@
/* SourcePool — пул cuframes-источников композитора (Phase 11b).
*
* Каждая запись: cuframes_key + frigate_camera + priority + cfc_source_t* +
* motion state (last_motion_ms, zone-filter). Pool создаётся при старте
* композитора (через add() вызовы) и живёт всю сессию.
*
* Cells (CameraCell) держат non-owning указатели на cfc_source_t — pool
* владеет.
*
* Лицензия: LGPL-2.1+
*/
#ifndef CUFRAMES_COMPOSER_CPP_SOURCE_POOL_HPP
#define CUFRAMES_COMPOSER_CPP_SOURCE_POOL_HPP
#include "../source.h"
#include <atomic>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
namespace cfc {
struct PoolEntry {
std::string cuframes_key;
std::string frigate_camera;
int priority = 0;
cfc_source_t* source = nullptr;
std::atomic<std::int64_t> last_motion_ms{0};
std::vector<std::string> required_zones;
/* Получить snapshot для drawable-checks без локов. */
cfc_source_state_t state() const {
if (!source) return CFC_SOURCE_DISCONNECTED;
cfc_source_snapshot_t s{};
cfc_source_get_latest(source, &s);
return s.state;
}
bool drawable() const {
cfc_source_state_t st = state();
return st == CFC_SOURCE_ACTIVE || st == CFC_SOURCE_STALE;
}
};
class SourcePool {
public:
SourcePool() = default;
~SourcePool();
SourcePool(const SourcePool&) = delete;
SourcePool& operator=(const SourcePool&) = delete;
/* Параметры подписки cuframes (default per cfc_source_config_t). */
struct SubscribeOpts {
int cuda_device = 0;
std::string consumer_prefix = "composer";
int reconnect_min_ms = 1000;
int reconnect_max_ms = 30000;
int stale_threshold_ms = 500;
int dead_threshold_ms = 5000;
};
/* Добавить источник в pool. Возвращает индекс или -1. */
int add(const std::string& cuframes_key,
const std::string& frigate_camera,
int priority,
const std::vector<std::string>& zones,
const SubscribeOpts& opts);
int size() const { return static_cast<int>(entries_.size()); }
PoolEntry* by_index(int i) { return i >= 0 && i < size() ? entries_[i].get() : nullptr; }
PoolEntry* by_key(const std::string& key);
/* Уведомить о motion (вызывается из Frigate MQTT subscriber'а через
* C-shim). Если zone-filter задан — проверяет пересечение. */
void motion_pulse(const std::string& frigate_camera,
const std::vector<std::string>& current_zones);
/* Итерация (для best-fit selection и health). */
template <typename F>
void for_each(F&& fn) {
for (auto& e : entries_) fn(*e);
}
private:
std::vector<std::unique_ptr<PoolEntry>> entries_;
std::mutex mu_;
};
} // namespace cfc
#endif /* CUFRAMES_COMPOSER_CPP_SOURCE_POOL_HPP */
@@ -0,0 +1,69 @@
/* Layout template — описание сетки в микроячейках (Phase 11b).
*
* Template — declarative описание layout'а: имя, набор CellTemplate
* (col/row/cs/rs/role/order/widget). Layout::apply_template() из template'а
* + SourcePool создаёт конкретные Cell-объекты (CameraCell/WidgetCell).
*
* Грид: 8×8 микроячейки на output W×H. Для 1920×1080 микроячейка = 240×135 (16:9).
*
* Загружается из JSON через template_loader.
*
* Лицензия: LGPL-2.1+
*/
#ifndef CUFRAMES_COMPOSER_CPP_TEMPLATE_HPP
#define CUFRAMES_COMPOSER_CPP_TEMPLATE_HPP
#include "types.hpp"
#include <string>
#include <vector>
namespace cfc {
constexpr int kGridCols = 8;
constexpr int kGridRows = 8;
enum class CellRole {
Camera = 0,
Widget = 1,
};
struct CellTemplate {
int col = 0, row = 0;
int cs = 1, rs = 1;
CellRole role = CellRole::Camera;
int order = 0;
std::string widget; /* имя widget'а для role=Widget */
};
struct LayoutTemplate {
std::string name;
int priority = 0;
std::vector<CellTemplate> cells;
int nb_camera_cells() const {
int n = 0;
for (auto& c : cells) if (c.role == CellRole::Camera) ++n;
return n;
}
};
/* Перевести {col,row,cs,rs} в pixel-rect для output W×H. */
inline Rect to_pixels(const CellTemplate& c, int W, int H)
{
Rect r;
r.x = (c.col * W) / kGridCols;
r.y = (c.row * H) / kGridRows;
r.w = (c.cs * W) / kGridCols;
r.h = (c.rs * H) / kGridRows;
/* NV12 4:2:0 — чётные. */
r.x &= ~1; r.y &= ~1; r.w &= ~1; r.h &= ~1;
if (r.x + r.w > W) r.w = W - r.x;
if (r.y + r.h > H) r.h = H - r.y;
return r;
}
} // namespace cfc
#endif /* CUFRAMES_COMPOSER_CPP_TEMPLATE_HPP */
@@ -0,0 +1,27 @@
/* Template loader — JSON → vector<LayoutTemplate> (Phase 11b).
*
* Schema см. docker/templates.json. При неудаче возвращает empty vector
* (caller использует built-in fallback).
*/
#ifndef CUFRAMES_COMPOSER_CPP_TEMPLATE_LOADER_HPP
#define CUFRAMES_COMPOSER_CPP_TEMPLATE_LOADER_HPP
#include "template.hpp"
#include <string>
#include <vector>
namespace cfc {
/* Загрузить из файла. Возвращает количество загруженных templates, либо
* отрицательное число при ошибке (-1=parse, -2=schema, -3=open). */
int load_templates_from_file(const std::string& path,
std::vector<LayoutTemplate>& out);
/* Встроенный набор fallback templates (Phase 11b base — single, quad). */
std::vector<LayoutTemplate> builtin_templates();
} // namespace cfc
#endif /* CUFRAMES_COMPOSER_CPP_TEMPLATE_LOADER_HPP */
@@ -0,0 +1,34 @@
/* WidgetCell — заглушка для widget'а (Phase 11b MVP).
*
* Фаза 11b: рисует cell тёмно-серым (Y=40) + label-decoration с именем
* widget'а в центре. Реальные виджеты (graph, ha_chat) — Phase 12+.
*
* Лицензия: LGPL-2.1+
*/
#ifndef CUFRAMES_COMPOSER_CPP_WIDGET_CELL_HPP
#define CUFRAMES_COMPOSER_CPP_WIDGET_CELL_HPP
#include "cell.hpp"
#include <string>
namespace cfc {
class WidgetCell : public Cell {
public:
WidgetCell(const Rect& geom, const std::string& widget_name)
: Cell(geom), widget_name_(widget_name) {}
const std::string& widget_name() const noexcept { return widget_name_; }
protected:
void draw_content(CUstream stream, NV12Ref& dst) override;
private:
std::string widget_name_;
};
} // namespace cfc
#endif /* CUFRAMES_COMPOSER_CPP_WIDGET_CELL_HPP */