diff --git a/pj_base/include/pj_base/builtin/image_annotations.hpp b/pj_base/include/pj_base/builtin/image_annotations.hpp index d03fa95c..d20f2720 100644 --- a/pj_base/include/pj_base/builtin/image_annotations.hpp +++ b/pj_base/include/pj_base/builtin/image_annotations.hpp @@ -19,6 +19,7 @@ #include #include +#include "pj_base/point2.hpp" #include "pj_base/types.hpp" namespace PJ { @@ -32,12 +33,8 @@ enum class AnnotationTopology : uint8_t { kLineLoop, ///< Like LineStrip but closes back to the first point. 4-point loop = rectangle. }; -/// 2D point in image-pixel coordinates (origin top-left). -struct Point2 { - double x = 0.0; - double y = 0.0; - bool operator==(const Point2&) const = default; -}; +// `Point2` lives in pj_base/point2.hpp (generic 2D vocab type). In this header +// it's used in image-pixel coordinates (origin top-left). /// 8-bit per-channel RGBA color. a=0 means transparent / disabled. struct ColorRGBA { diff --git a/pj_base/include/pj_base/point2.hpp b/pj_base/include/pj_base/point2.hpp new file mode 100644 index 00000000..725a9925 --- /dev/null +++ b/pj_base/include/pj_base/point2.hpp @@ -0,0 +1,18 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// Plain (x, y) 2D point. Generic double-precision vocab type — used by image +// annotations (pixel coordinates), Filter Editor transforms (time/value +// samples), and any other 2D context where a tagged shape would be overkill. +// The semantic of x / y is owned by the caller. + +namespace PJ::sdk { + +struct Point2 { + double x = 0.0; + double y = 0.0; + bool operator==(const Point2&) const = default; +}; + +} // namespace PJ::sdk diff --git a/pj_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index f052da2d..32859e98 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -17,6 +17,25 @@ target_link_libraries(pj_plugin_loader_detail PUBLIC pj_base) add_subdirectory(dialog_protocol) +# Filter Editor transform contract (header-only) — plugins implement the 12 +# concrete strategies and self-register with the factory at load time. See +# pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform.hpp. +add_library(pj_filter_sdk INTERFACE) +target_compile_features(pj_filter_sdk INTERFACE cxx_std_20) +target_include_directories(pj_filter_sdk INTERFACE + $ + $ +) +# Builtin strategies (in the plugin) use nlohmann/json for saveParams / +# loadParams; propagate the dep so consumers get it transitively. Also pulls +# pj_base for the C ABI primitives (PJ_string_view_t, PJ_error_t, PJ_NOEXCEPT) +# referenced by the filter registry service. +target_link_libraries(pj_filter_sdk INTERFACE pj_base nlohmann_json::nlohmann_json) +set_target_properties(pj_filter_sdk PROPERTIES EXPORT_NAME filter_sdk) +add_library(plotjuggler_sdk::filter_sdk ALIAS pj_filter_sdk) +install(DIRECTORY filter_protocol/include/ DESTINATION include) +install(TARGETS pj_filter_sdk EXPORT plotjuggler_sdkTargets ARCHIVE DESTINATION lib LIBRARY DESTINATION lib INCLUDES DESTINATION include) + # --------------------------------------------------------------------------- # pj_plugin_sdk — umbrella INTERFACE library that exposes the full plugin-author # SDK surface: C++ SDK headers (MessageParserPluginBase, ObjectIngestPolicy, @@ -29,7 +48,7 @@ target_include_directories(pj_plugin_sdk INTERFACE $ $ ) -target_link_libraries(pj_plugin_sdk INTERFACE pj_base pj_dialog_sdk) +target_link_libraries(pj_plugin_sdk INTERFACE pj_base pj_dialog_sdk pj_filter_sdk) set_target_properties(pj_plugin_sdk PROPERTIES EXPORT_NAME plugin_sdk) add_library(plotjuggler_sdk::plugin_sdk ALIAS pj_plugin_sdk) diff --git a/pj_plugins/filter_protocol/include/pj_plugins/sdk/builtin_transforms.hpp b/pj_plugins/filter_protocol/include/pj_plugins/sdk/builtin_transforms.hpp new file mode 100644 index 00000000..05d06f22 --- /dev/null +++ b/pj_plugins/filter_protocol/include/pj_plugins/sdk/builtin_transforms.hpp @@ -0,0 +1,799 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: MPL-2.0 + +// Built-in Filter Transform catalogue — concrete classes vendored in the SDK +// so the Filter Editor plugin and the host (PJ4 app) consume the SAME math +// from ONE source. Each class implements PJ::sdk::FilterTransform from +// filter_transform.hpp. +// +// Mirrors PJ3's TransformFunction_SISO catalogue: each class owns its +// parameters, implements calculateNextPoint() for SISO streaming, persists +// itself via saveParams() / loadParams(), and clones via clone(). Pure C++20 — +// no Qt, no Lua, no datastore — fully unit-testable in isolation. +// +// Registration: the Filter Editor plugin's loaderInit walks the list of +// classes below and registers them into the host's filter registry service +// (pj.filter_registry.v1). The host's FilterTransformFactory holds all +// entries — preview, layout restore, and the streaming read path all resolve +// through the same instance. The JSON wire format produced by saveParams() / +// loadParams() is what crosses the plugin/host DSO boundary for parameters. + +#include +#include +#include +#include +#include +#include +#include +#include + +// nlohmann/json is required for per-transform parameter persistence. Guard the +// include so the classes can be exercised through the computation API alone +// (e.g. from a test that does not link nlohmann_json). +#ifdef NLOHMANN_JSON_HPP +#define PJ_TRANSFORM_HAS_JSON 1 +#else +#ifdef __has_include +#if __has_include() +#include +#define PJ_TRANSFORM_HAS_JSON 1 +#endif +#endif +#endif + +#include "pj_plugins/sdk/filter_transform.hpp" + +namespace PJ::sdk { + +// --------------------------------------------------------------------------- +// Concrete transforms +// --------------------------------------------------------------------------- + +// --- None (passthrough) --- + +class NoneTransform : public FilterTransform { + public: + const char* id() const override { + return "none"; + } + const char* label() const override { + return "-- No Transform --"; + } + const char* bracketLabel() const override { + return "copy"; + } + bool isStreamSafe() const override { + return true; + } + void reset() override {} + std::optional calculateNextPoint(const Point2& in) override { + return in; + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } +}; + +// --- Absolute --- + +class AbsoluteTransform : public FilterTransform { + public: + const char* id() const override { + return "absolute"; + } + const char* label() const override { + return "Absolute"; + } + const char* bracketLabel() const override { + return "Absolute"; + } + bool isStreamSafe() const override { + return true; + } + void reset() override {} + std::optional calculateNextPoint(const Point2& in) override { + return Point2{in.x, std::abs(in.y)}; + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } +}; + +// --- Scale / Offset --- + +class ScaleTransform : public FilterTransform { + public: + double value_scale = 1.0; + double value_offset = 0.0; + double time_offset = 0.0; + + const char* id() const override { + return "scale"; + } + const char* label() const override { + return "Scale/Offset"; + } + const char* bracketLabel() const override { + return "Scale"; + } + bool isStreamSafe() const override { + return true; + } + void reset() override {} + std::optional calculateNextPoint(const Point2& in) override { + return Point2{in.x + time_offset, value_scale * in.y + value_offset}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["value_scale"] = value_scale; + j["value_offset"] = value_offset; + j["time_offset"] = time_offset; + return j.dump(); +#else + return "{}"; +#endif + } + void loadParams(const std::string& json_str) override { + (void)json_str; +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + value_scale = j.value("value_scale", 1.0); + value_offset = j.value("value_offset", 0.0); + time_offset = j.value("time_offset", 0.0); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } +}; + +// --- Derivative --- + +class DerivativeTransform : public FilterTransform { + public: + bool use_custom_dt = false; + double custom_dt = 1.0; + + const char* id() const override { + return "derivative"; + } + const char* label() const override { + return "Derivative"; + } + const char* bracketLabel() const override { + return "Derivative"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + prev_ = std::nullopt; + } + + std::optional calculateNextPoint(const Point2& in) override { + if (!prev_.has_value()) { + prev_ = in; + return std::nullopt; + } + const double dt = use_custom_dt ? custom_dt : (in.x - prev_->x); + if (dt <= 0.0) { + prev_ = in; + return std::nullopt; + } + const Point2 out{prev_->x, (in.y - prev_->y) / dt}; + prev_ = in; + return out; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["use_custom_dt"] = use_custom_dt; + j["custom_dt"] = custom_dt; + return j.dump(); +#else + return "{}"; +#endif + } + void loadParams(const std::string& json_str) override { + (void)json_str; +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + use_custom_dt = j.value("use_custom_dt", false); + custom_dt = j.value("custom_dt", 1.0); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::optional prev_; +}; + +// --- Integral (trapezoid) --- + +class IntegralTransform : public FilterTransform { + public: + bool use_custom_dt = false; + double custom_dt = 1.0; + + const char* id() const override { + return "integral"; + } + const char* label() const override { + return "Integral"; + } + const char* bracketLabel() const override { + return "Integral"; + } + // Unbounded running accumulator: correct only when processed from the start + // in order; not incrementally safe in the streaming sense. + bool isStreamSafe() const override { + return false; + } + + void reset() override { + acc_ = 0.0; + prev_ = std::nullopt; + } + + std::optional calculateNextPoint(const Point2& in) override { + if (!prev_.has_value()) { + prev_ = in; + return std::nullopt; + } + const double dt = use_custom_dt ? custom_dt : (in.x - prev_->x); + if (dt > 0.0) { + acc_ += (in.y + prev_->y) * dt / 2.0; + } + const Point2 out{in.x, acc_}; + prev_ = in; + return out; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["use_custom_dt"] = use_custom_dt; + j["custom_dt"] = custom_dt; + return j.dump(); +#else + return "{}"; +#endif + } + void loadParams(const std::string& json_str) override { + (void)json_str; +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + use_custom_dt = j.value("use_custom_dt", false); + custom_dt = j.value("custom_dt", 1.0); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + double acc_ = 0.0; + std::optional prev_; +}; + +// --- Moving Average --- + +class MovingAverageTransform : public FilterTransform { + public: + int window = 10; + bool compensate_time_offset = false; + + const char* id() const override { + return "moving_average"; + } + const char* label() const override { + return "Moving Average"; + } + const char* bracketLabel() const override { + return "Moving Average"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + buf_.clear(); + } + + std::optional calculateNextPoint(const Point2& in) override { + buf_.push_back(in); + const size_t w = static_cast(std::max(1, window)); + while (buf_.size() > w) { + buf_.erase(buf_.begin()); + } + // Pad underfull window with current point (PJ3 semantics) + double total = in.y * static_cast(w - buf_.size()); + for (const auto& p : buf_) { + total += p.y; + } + double t = in.x; + if (compensate_time_offset && buf_.size() > 1) { + t = (buf_.front().x + buf_.back().x) / 2.0; + } + return Point2{t, total / static_cast(w)}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["window"] = window; + j["compensate_time_offset"] = compensate_time_offset; + return j.dump(); +#else + return "{}"; +#endif + } + void loadParams(const std::string& json_str) override { + (void)json_str; +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + window = j.value("window", 10); + compensate_time_offset = j.value("compensate_time_offset", false); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::vector buf_; +}; + +// --- Moving RMS --- + +class MovingRMSTransform : public FilterTransform { + public: + int window = 10; + + const char* id() const override { + return "moving_rms"; + } + const char* label() const override { + return "Moving Root Mean Squared"; + } + const char* bracketLabel() const override { + return "Moving Root Mean Squared"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + buf_.clear(); + } + + std::optional calculateNextPoint(const Point2& in) override { + buf_.push_back(in); + const size_t w = static_cast(std::max(1, window)); + while (buf_.size() > w) { + buf_.erase(buf_.begin()); + } + double total_sqr = in.y * in.y * static_cast(w - buf_.size()); + for (const auto& p : buf_) { + total_sqr += p.y * p.y; + } + return Point2{in.x, std::sqrt(total_sqr / static_cast(w))}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["window"] = window; + return j.dump(); +#else + return "{}"; +#endif + } + void loadParams(const std::string& json_str) override { + (void)json_str; +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + window = j.value("window", 10); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::vector buf_; +}; + +// --- Moving Variance / Stdev --- + +class MovingVarianceTransform : public FilterTransform { + public: + int window = 10; + bool std_dev = false; // true → output sqrt(variance) + + const char* id() const override { + return "moving_variance"; + } + const char* label() const override { + return "Moving Variance / Stdev"; + } + const char* bracketLabel() const override { + return "Moving Variance / Stdev"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + buf_.clear(); + } + + std::optional calculateNextPoint(const Point2& in) override { + buf_.push_back(in); + const size_t w = static_cast(std::max(1, window)); + while (buf_.size() > w) { + buf_.erase(buf_.begin()); + } + const double pad = static_cast(w - buf_.size()); + double total = in.y * pad; + for (const auto& p : buf_) { + total += p.y; + } + const double avg = total / static_cast(w); + double total_sqr = (in.y - avg) * (in.y - avg) * pad; + for (const auto& p : buf_) { + const double v = p.y - avg; + total_sqr += v * v; + } + const double var = total_sqr / static_cast(w); + return Point2{in.x, std_dev ? std::sqrt(var) : var}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["window"] = window; + j["std_dev"] = std_dev; + return j.dump(); +#else + return "{}"; +#endif + } + void loadParams(const std::string& json_str) override { + (void)json_str; +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + window = j.value("window", 10); + std_dev = j.value("std_dev", false); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::vector buf_; +}; + +// --- Outlier Removal --- + +class OutlierRemovalTransform : public FilterTransform { + public: + double outlier_factor = 100.0; + + const char* id() const override { + return "outlier_removal"; + } + const char* label() const override { + return "Outlier Removal"; + } + const char* bracketLabel() const override { + return "Outlier Removal"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + buf_.clear(); + } + + // Overrides applyBatch: needs 4-sample look-ahead ring (PJ3 semantics). + std::vector applyBatch(const std::vector& input) override { + const size_t n = input.size(); + std::vector out; + out.reserve(n); + for (size_t i = 0; i < n; ++i) { + if (i < 3) { + out.push_back(input[i]); + continue; + } + const double d1 = input[i - 2].y - input[i - 1].y; + const double d2 = input[i - 1].y - input[i].y; + bool drop = false; + if (d1 * d2 < 0) { + const double d0 = input[i - 3].y - input[i - 2].y; + const double jump = std::max(std::abs(d1), std::abs(d2)); + const double ratio = (d0 == 0.0) ? std::numeric_limits::infinity() : jump / std::abs(d0); + if (ratio > outlier_factor) { + drop = true; + } + } + if (!drop) { + // Intentional PJ3 parity: emit the *previous* sample (i-1), not the + // current one (i). The detector needs one look-ahead sample to decide + // whether i-1 is an outlier, so the series is delayed by one sample. + out.push_back({input[i - 1].x, input[i - 1].y}); + } + } + return out; + } + + // calculateNextPoint not used (applyBatch overridden), but required by interface. + std::optional calculateNextPoint(const Point2& in) override { + buf_.push_back(in); + if (buf_.size() < 4) { + return in; + } + const size_t i = buf_.size() - 1; + const double d1 = buf_[i - 2].y - buf_[i - 1].y; + const double d2 = buf_[i - 1].y - buf_[i].y; + if (d1 * d2 < 0) { + const double d0 = buf_[i - 3].y - buf_[i - 2].y; + const double jump = std::max(std::abs(d1), std::abs(d2)); + const double ratio = (d0 == 0.0) ? std::numeric_limits::infinity() : jump / std::abs(d0); + if (ratio > outlier_factor) { + return std::nullopt; + } + } + return Point2{buf_[i - 1].x, buf_[i - 1].y}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["outlier_factor"] = outlier_factor; + return j.dump(); +#else + return "{}"; +#endif + } + void loadParams(const std::string& json_str) override { + (void)json_str; +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + outlier_factor = j.value("outlier_factor", 100.0); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::vector buf_; +}; + +// --- Samples Counter --- + +class SamplesCounterTransform : public FilterTransform { + public: + int samples_ms = 1000; + + const char* id() const override { + return "samples_counter"; + } + const char* label() const override { + return "Samples Counter"; + } + const char* bracketLabel() const override { + return "Samples Counter"; + } + // Time-windowed look-back: needs all prior samples in the window. + bool isStreamSafe() const override { + return false; + } + + void reset() override { + all_.clear(); + } + + // Overrides applyBatch for correct time-window semantics. + std::vector applyBatch(const std::vector& input) override { + const size_t n = input.size(); + const double delta = 0.001 * static_cast(samples_ms); + std::vector out; + out.reserve(n); + for (size_t i = 0; i < n; ++i) { + const double min_t = input[i].x - delta; + size_t lo = 0, hi = i + 1; + while (lo < hi) { + const size_t mid = lo + (hi - lo) / 2; + if (input[mid].x < min_t) { + lo = mid + 1; + } else { + hi = mid; + } + } + out.push_back({input[i].x, static_cast(i - lo)}); + } + return out; + } + + std::optional calculateNextPoint(const Point2& in) override { + all_.push_back(in); + const double delta = 0.001 * static_cast(samples_ms); + const double min_t = in.x - delta; + size_t count = 0; + for (size_t i = all_.size(); i-- > 0;) { + if (all_[i].x < min_t) { + break; + } + ++count; + } + return Point2{in.x, static_cast(count - 1)}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["samples_ms"] = samples_ms; + return j.dump(); +#else + return "{}"; +#endif + } + void loadParams(const std::string& json_str) override { + (void)json_str; +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + samples_ms = j.value("samples_ms", 1000); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::vector all_; +}; + +// --- Binary Filter --- + +enum class BinaryOp { kEqual, kLess, kLessEq, kGreater, kGreaterEq, kRange }; + +class BinaryFilterTransform : public FilterTransform { + public: + BinaryOp op = BinaryOp::kGreater; + double a = 0.0; + double b = 0.0; + + const char* id() const override { + return "binary_filter"; + } + const char* label() const override { + return "Binary Filter"; + } + const char* bracketLabel() const override { + return "Binary Filter"; + } + bool isStreamSafe() const override { + return true; + } + void reset() override {} + + std::optional calculateNextPoint(const Point2& in) override { + double r = 0.0; + switch (op) { + case BinaryOp::kEqual: + r = (std::abs(in.y - a) <= 1e-9 * std::max(1.0, std::abs(a))) ? 1.0 : 0.0; + break; + case BinaryOp::kLess: + r = (in.y < a) ? 1.0 : 0.0; + break; + case BinaryOp::kLessEq: + r = (in.y <= a) ? 1.0 : 0.0; + break; + case BinaryOp::kGreater: + r = (in.y > a) ? 1.0 : 0.0; + break; + case BinaryOp::kGreaterEq: + r = (in.y >= a) ? 1.0 : 0.0; + break; + case BinaryOp::kRange: { + const double lo = std::min(a, b), hi = std::max(a, b); + r = (in.y >= lo && in.y <= hi) ? 1.0 : 0.0; + break; + } + } + return Point2{in.x, r}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["binary_op"] = static_cast(op); + j["binary_a"] = a; + j["binary_b"] = b; + return j.dump(); +#else + return "{}"; +#endif + } + void loadParams(const std::string& json_str) override { + (void)json_str; +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + op = static_cast(j.value("binary_op", static_cast(BinaryOp::kGreater))); + a = j.value("binary_a", 0.0); + b = j.value("binary_b", 0.0); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } +}; + +// --- Time Since Previous Point2 --- + +class TimeSincePreviousTransform : public FilterTransform { + public: + const char* id() const override { + return "time_since_previous"; + } + const char* label() const override { + return "Time Since Previous Point2"; + } + const char* bracketLabel() const override { + return "Time Since Previous Point2"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + prev_t_ = std::nullopt; + } + + std::optional calculateNextPoint(const Point2& in) override { + if (!prev_t_.has_value()) { + prev_t_ = in.x; + return std::nullopt; + } + const Point2 out{in.x, in.x - *prev_t_}; + prev_t_ = in.x; + return out; + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::optional prev_t_; +}; + +} // namespace PJ::sdk diff --git a/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_registry_abi.h b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_registry_abi.h new file mode 100644 index 00000000..51c0734d --- /dev/null +++ b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_registry_abi.h @@ -0,0 +1,128 @@ +/** + * @file filter_registry_abi.h + * @brief C ABI for the Filter Transform Registry host service (pj.filter_registry.v1). + * + * The host owns a single FilterTransformFactory exposed to plugins via this + * service. Plugins register their transform classes during loaderInit (each + * registration carries a per-DSO library_owner token so the host pins the + * plugin DSO while any registered factory function is reachable), and resolve + * transforms by id from the same registry — preview, layout restore, and the + * streaming read path all go through the same instances. + * + * Every vtable slot is PJ_NOEXCEPT. Throws across the boundary terminate. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#ifndef PJ_FILTER_REGISTRY_ABI_H +#define PJ_FILTER_REGISTRY_ABI_H + +#include +#include +#include + +#include "pj_base/plugin_data_api.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** Service id for the filter registry. v1 == minimum protocol version. */ +#define PJ_FILTER_REGISTRY_SERVICE_NAME "pj.filter_registry.v1" + +/** Opaque handle to a FilterTransform instance. Lifetime is owned by the + * caller; release via the vtable's `destroy_transform` slot. */ +typedef struct PJ_filter_transform PJ_filter_transform_t; + +/** Factory function the plugin registers. The host invokes it (under the + * plugin's pinned DSO via the library_owner token) to create instances on + * demand. Must not throw across the boundary; return NULL on failure. */ +typedef PJ_filter_transform_t* (*PJ_filter_transform_factory_fn)(void* user_ctx)PJ_NOEXCEPT; + +/** Host-owned destructor for an instance created by the factory above. The + * host calls into this when releasing the instance — keeps the delete on + * the same side that did the new (mirrors `unique_ptr<…, deleter>`). */ +typedef void (*PJ_filter_transform_deleter_fn)(PJ_filter_transform_t*) PJ_NOEXCEPT; + +/** Plugin's registration payload. The host stores a copy. */ +typedef struct PJ_filter_transform_registration_t { + /** Stable id (e.g. "moving_average"). Must outlive the registration. */ + PJ_string_view_t id; + /** Constructor invoked by host to materialise instances. */ + PJ_filter_transform_factory_fn factory; + /** Destructor for instances produced by `factory`. */ + PJ_filter_transform_deleter_fn deleter; + /** Opaque context passed to `factory` (typically the plugin's own state + * or nullptr; the host does not interpret it). */ + void* factory_ctx; +} PJ_filter_transform_registration_t; + +/* ABI-APPENDABLE: new slots may be added at the tail; struct_size gates read. */ +typedef struct PJ_filter_registry_vtable_t { + uint32_t protocol_version; + uint32_t struct_size; + + /** + * Register a transform under @p reg.id. The library_owner token (opaque + * to the vtable but typically the plugin DSO handle) pins the plugin so + * its factory/deleter code stays reachable until every produced instance + * is destroyed. Replaces any previous registration under the same id. + * + * Thread-class: [thread-safe] + */ + bool (*register_transform)( + void* ctx, PJ_filter_transform_registration_t reg, void* library_owner, PJ_error_t* out_error) PJ_NOEXCEPT; + + /** + * Drop the registration under @p id. Existing instances stay alive + * (their deleter is still callable through the cached library_owner ref). + * + * Thread-class: [thread-safe] + */ + bool (*unregister_transform)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT; + + /** + * Resolve a transform id and create an instance. The caller owns the + * returned handle and must release it via the same registration's + * deleter (reachable through `lookup_deleter` below). + * + * Thread-class: [thread-safe] + */ + PJ_filter_transform_t* (*create_transform)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error)PJ_NOEXCEPT; + + /** + * Look up the deleter for instances created under @p id. Lets the caller + * destroy an instance even after the registration entry has been + * replaced (the deleter pointer + library_owner ref are captured at + * `create_transform` time and remain valid until the instance dies). + * + * Thread-class: [thread-safe] + */ + PJ_filter_transform_deleter_fn (*lookup_deleter)(void* ctx, PJ_string_view_t id) PJ_NOEXCEPT; + + /** + * Iterate all registered ids in registration order. The host fills @p + * out_ids up to @p capacity entries and writes the actual count to + * @p out_count. Pass capacity==0 to size the buffer first. + * + * Thread-class: [thread-safe] + */ + void (*list_ids)(void* ctx, PJ_string_view_t* out_ids, size_t capacity, size_t* out_count) PJ_NOEXCEPT; +} PJ_filter_registry_vtable_t; + +/** Pinned minimum vtable size for v1.0; never grows when tail slots are + * appended. Loaders reject services whose `struct_size < this`. */ +#define PJ_FILTER_REGISTRY_MIN_VTABLE_SIZE \ + (offsetof(PJ_filter_registry_vtable_t, list_ids) + sizeof(void (*)(void*, PJ_string_view_t*, size_t, size_t*))) + +/** Fat pointer for the filter registry service. */ +typedef struct PJ_filter_registry_t { + void* ctx; + const PJ_filter_registry_vtable_t* vtable; +} PJ_filter_registry_t; + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // PJ_FILTER_REGISTRY_ABI_H diff --git a/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_registry_service.hpp b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_registry_service.hpp new file mode 100644 index 00000000..dc58961d --- /dev/null +++ b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_registry_service.hpp @@ -0,0 +1,154 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +// C++ wrapper for the pj.filter_registry.v1 service. Plugins receive a handle +// via their bind() and use it to register their FilterTransform classes and to +// resolve transforms by id. The host's FilterTransformFactory sits behind it. +// +// Cross-DSO note: the math (12 builtins) is vendored in `builtin_transforms.hpp` +// from this SDK so both the plugin and the host compile the same classes. The +// service trades only an opaque handle + a paired deleter, so the host calls +// virtual methods on FilterTransform via the in-DSO vtable of whichever side +// created the instance. The library_owner shared_ptr pins the plugin DSO +// for as long as any of its registered factory_fn / deleter pairs is live. + +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/expected.hpp" +#include "pj_plugins/sdk/filter_registry_abi.h" +#include "pj_plugins/sdk/filter_transform.hpp" + +namespace PJ::sdk { + +/// Typed C++ view over PJ_filter_registry_t. Constructed from the fat pointer +/// returned by ServiceRegistry::require(). +class FilterRegistryView { + public: + constexpr FilterRegistryView() = default; + constexpr explicit FilterRegistryView(PJ_filter_registry_t raw) noexcept : raw_(raw) {} + + [[nodiscard]] bool valid() const noexcept { + return raw_.ctx != nullptr && raw_.vtable != nullptr && raw_.vtable->register_transform != nullptr && + raw_.vtable->create_transform != nullptr; + } + + /// Register a FilterTransform class. `Class` must be default-constructible + /// and inherit from FilterTransform; `id` is the lookup key (e.g. "scale"). + /// + /// Pass any object the caller wants the host to pin while this registration + /// is live as `library_owner` — typically the plugin DSO handle obtained + /// from the v4 bind context (see DataSourceHandle::libraryOwner()). + template + [[nodiscard]] Expected registerTransform(std::string id, std::shared_ptr library_owner) { + static_assert(std::is_base_of_v, "Class must inherit FilterTransform"); + auto* factory = + +[](void*) noexcept -> PJ_filter_transform_t* { return reinterpret_cast(new Class{}); }; + auto* deleter = +[](PJ_filter_transform_t* p) noexcept { delete reinterpret_cast(p); }; + return registerRaw(std::move(id), factory, deleter, nullptr, std::move(library_owner)); + } + + /// Drop a registration. Existing instances stay alive — their deleter was + /// captured at create time and remains callable. + [[nodiscard]] Expected unregisterTransform(std::string_view id) { + PJ_error_t err{}; + PJ_string_view_t id_view{id.data(), id.size()}; + if (!raw_.vtable->unregister_transform(raw_.ctx, id_view, &err)) { + return unexpected(std::string("unregisterTransform failed: ") + err.message); + } + return {}; + } + + /// Create an instance by id. Returns nullptr if `id` is unknown or the + /// registered factory fails. The returned shared_ptr's deleter routes + /// through the original registration so the destruction happens in the + /// same DSO that did the new (deleter + library_owner captured at create). + [[nodiscard]] std::shared_ptr create(std::string_view id) const { + if (!valid()) { + return nullptr; + } + PJ_error_t err{}; + PJ_string_view_t id_view{id.data(), id.size()}; + PJ_filter_transform_t* raw = raw_.vtable->create_transform(raw_.ctx, id_view, &err); + if (raw == nullptr) { + return nullptr; + } + PJ_filter_transform_deleter_fn deleter = raw_.vtable->lookup_deleter(raw_.ctx, id_view); + if (deleter == nullptr) { + // Should not happen — registered factories always have a deleter — but + // defend: leak the instance rather than UB if it ever does. + return nullptr; + } + return std::shared_ptr( + reinterpret_cast(raw), + [deleter](FilterTransform* p) noexcept { deleter(reinterpret_cast(p)); }); + } + + /// Snapshot of registered ids in registration order. Allocates a vector + /// of strings copied out of the service. + [[nodiscard]] std::vector registeredIds() const { + if (!valid()) { + return {}; + } + size_t count = 0; + raw_.vtable->list_ids(raw_.ctx, nullptr, 0, &count); + if (count == 0) { + return {}; + } + std::vector raw_ids(count); + size_t actual = 0; + raw_.vtable->list_ids(raw_.ctx, raw_ids.data(), count, &actual); + std::vector out; + out.reserve(actual); + for (size_t i = 0; i < actual; ++i) { + out.emplace_back(raw_ids[i].data, raw_ids[i].size); + } + return out; + } + + [[nodiscard]] PJ_filter_registry_t raw() const noexcept { + return raw_; + } + + private: + [[nodiscard]] Expected registerRaw( + std::string id, PJ_filter_transform_factory_fn factory, PJ_filter_transform_deleter_fn deleter, void* factory_ctx, + std::shared_ptr library_owner) { + PJ_filter_transform_registration_t reg{}; + reg.id = PJ_string_view_t{id.data(), id.size()}; + reg.factory = factory; + reg.deleter = deleter; + reg.factory_ctx = factory_ctx; + PJ_error_t err{}; + // The host's register_transform copies `id` and keeps a strong ref on + // `library_owner` for as long as the entry lives. + if (!raw_.vtable->register_transform(raw_.ctx, reg, library_owner.get(), &err)) { + return unexpected(std::string("registerTransform failed: ") + err.message); + } + // We hand the host an opaque void* to the shared_ptr's managed object; + // for the host to actually pin the DSO it must wrap the void* into a + // shared_ptr on its side using a deleter that releases this one. + // Implementations of the service do that wiring at register time. + (void)library_owner; // ownership transferred via the host registry + return {}; + } + + PJ_filter_registry_t raw_{}; +}; + +/// Traits for ServiceRegistry::get<>/require<>(). +struct FilterRegistryService { + static constexpr const char* kName = PJ_FILTER_REGISTRY_SERVICE_NAME; + static constexpr uint32_t kMinVersion = 1; + using Raw = PJ_filter_registry_t; + using Vtable = PJ_filter_registry_vtable_t; + using View = FilterRegistryView; +}; + +} // namespace PJ::sdk diff --git a/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform.hpp b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform.hpp new file mode 100644 index 00000000..02c40188 --- /dev/null +++ b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform.hpp @@ -0,0 +1,78 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +// Filter Editor transform contract. Plugins provide the concrete strategies; +// the host consumes them by id through FilterTransformFactory. + +#include +#include +#include +#include + +#include "pj_base/point2.hpp" + +namespace PJ::sdk { + +/// Strategy interface for a Filter Editor transform. +/// +/// Two streaming surfaces: +/// - `calculateNextPoint` — SISO, one input -> at most one output (PJ3 mirror). +/// - `appendTail` — process a chunk; default loops `calculateNextPoint`. Override +/// when running state (sliding sum, deque) makes it O(Δsamples) per call. +/// +/// `clone()` is the host hand-off across the plugin DSO boundary. +class FilterTransform { + public: + virtual ~FilterTransform() = default; + + // Catalog identity used in saveConfig JSON, the factory registry, and the + // legend. + [[nodiscard]] virtual const char* id() const = 0; + [[nodiscard]] virtual const char* label() const = 0; + [[nodiscard]] virtual const char* bracketLabel() const = 0; + + // True if the transform can extend its output as new samples arrive. + [[nodiscard]] virtual bool isStreamSafe() const = 0; + + /// Drop accumulated state. Must be called before the first `calculateNextPoint` + /// after a series replace / clear. + virtual void reset() = 0; + + /// One input -> optional output. Inputs MUST arrive in x-ascending order. + /// nullopt suppresses the output (e.g. Derivative drops the first sample). + [[nodiscard]] virtual std::optional calculateNextPoint(const Point2& in) = 0; + + /// Process the tail of points since the previous call. Default loops + /// `calculateNextPoint`; override per-transform when O(Δsamples) is possible. + virtual void appendTail(const std::vector& new_raw, std::vector& out) { + out.reserve(out.size() + new_raw.size()); + for (const auto& p : new_raw) { + if (auto r = calculateNextPoint(p); r) { + out.push_back(*r); + } + } + } + + /// Run from scratch over a whole series. Default: reset + appendTail. + virtual std::vector applyBatch(const std::vector& input) { + reset(); + std::vector out; + out.reserve(input.size()); + appendTail(input, out); + return out; + } + + /// JSON for the parameter set this transform owns (not the source binding). + [[nodiscard]] virtual std::string saveParams() const { + return "{}"; + } + virtual void loadParams(const std::string& /*json_str*/) {} + + /// Deep copy. The host calls this so the kept instance is independent of the + /// plugin DSO (the cloned vtable lives in the plugin's code, which stays + /// loaded for the app session). + [[nodiscard]] virtual std::unique_ptr clone() const = 0; +}; + +} // namespace PJ::sdk diff --git a/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform_factory.hpp b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform_factory.hpp new file mode 100644 index 00000000..60641348 --- /dev/null +++ b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform_factory.hpp @@ -0,0 +1,118 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +// FilterTransform registry. The host owns a single instance and exposes it to +// plugins via the pj.filter_registry.v1 service (filter_registry_service.hpp); +// the plugin registers its classes at loaderInit and resolves them through the +// same registry for preview / saveParams / loadParams. Order of registration +// is preserved (mirrors PJ3's dropdown order). +// +// Each entry pins a `library_owner` shared_ptr so the host keeps the +// plugin DSO loaded for as long as any of its registered create_fn / +// deleter_fn pair could still run. + +#include +#include +#include +#include +#include +#include + +#include "pj_plugins/sdk/filter_transform.hpp" + +namespace PJ::sdk { + +class FilterTransformFactory { + public: + using CreateFn = std::function; + // No `noexcept` qualifier: std::function cannot specialise on a noexcept + // function type in C++20. The contract still requires the deleter not to + // throw — the host wraps any registered deleter so the noexcept guarantee + // is enforced at the C-ABI boundary instead. + using DeleterFn = std::function; + + /// Register a transform class under @p id. The caller passes a paired + /// `create_fn` / `delete_fn` so destruction happens on the same side that + /// allocated the instance (cross-DSO safety). `library_owner` keeps the + /// owning DSO loaded while the entry is live. + /// + /// Re-registering an existing id replaces the previous entry. + void registerTransform(std::string id, CreateFn create_fn, DeleterFn delete_fn, std::shared_ptr library_owner) { + for (auto& e : entries_) { + if (e.id == id) { + e.create_fn = std::move(create_fn); + e.delete_fn = std::move(delete_fn); + e.library_owner = std::move(library_owner); + return; + } + } + entries_.push_back({std::move(id), std::move(create_fn), std::move(delete_fn), std::move(library_owner)}); + } + + /// Drop the entry under @p id. Existing instances stay alive because their + /// destruction path captured the deleter + library_owner at create time. + void unregisterTransform(std::string_view id) { + for (auto it = entries_.begin(); it != entries_.end(); ++it) { + if (it->id == id) { + entries_.erase(it); + return; + } + } + } + + [[nodiscard]] std::vector registeredIds() const { + std::vector ids; + ids.reserve(entries_.size()); + for (const auto& e : entries_) { + ids.push_back(e.id); + } + return ids; + } + + /// Create an instance under @p id. Returns a shared_ptr whose deleter routes + /// through the entry's deleter_fn so destruction happens in the same DSO + /// that allocated it. Captures the library_owner shared_ptr in the deleter + /// so the DSO outlives the instance even if the entry is later unregistered + /// or replaced. Returns nullptr if @p id is not registered. + [[nodiscard]] std::shared_ptr create(std::string_view id) const { + for (const auto& e : entries_) { + if (e.id == id) { + FilterTransform* raw = e.create_fn(); + if (raw == nullptr) { + return nullptr; + } + auto deleter = e.delete_fn; + auto owner = e.library_owner; + return std::shared_ptr(raw, [deleter, owner](FilterTransform* p) { + deleter(p); + (void)owner; // owner ref drops here, after deleter — keeps DSO loaded + }); + } + } + return nullptr; + } + + /// Snapshot the deleter for an id. Used by the C-ABI service wrapper so it + /// can return a deleter to the cross-DSO caller (the in-process C++ API + /// uses create() directly and never needs this). + [[nodiscard]] DeleterFn lookupDeleter(std::string_view id) const { + for (const auto& e : entries_) { + if (e.id == id) { + return e.delete_fn; + } + } + return {}; + } + + private: + struct Entry { + std::string id; + CreateFn create_fn; + DeleterFn delete_fn; + std::shared_ptr library_owner; + }; + std::vector entries_; +}; + +} // namespace PJ::sdk diff --git a/pj_plugins/include/pj_plugins/host/filter_registry_host.hpp b/pj_plugins/include/pj_plugins/host/filter_registry_host.hpp new file mode 100644 index 00000000..3b6367f5 --- /dev/null +++ b/pj_plugins/include/pj_plugins/host/filter_registry_host.hpp @@ -0,0 +1,241 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// Host-side adapter: exposes a FilterTransformFactory as the +// pj.filter_registry.v1 C-ABI service so plugins can register their transform +// classes during loaderInit and resolve them later. The factory itself owns +// the entries; this header just wires the C trampolines and shared_ptr +// library_owner plumbing that the cross-DSO contract needs. +// +// Usage (host side): +// FilterTransformFactory factory; +// FilterRegistryHost host(factory); +// service_registry_builder.registerService(host.service()); +// +// Usage (plugin side, via the standard ServiceRegistry): +// auto view = services.require(); +// view.registerTransform("moving_average", libraryOwner()); + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_plugins/sdk/filter_registry_abi.h" +#include "pj_plugins/sdk/filter_registry_service.hpp" +#include "pj_plugins/sdk/filter_transform_factory.hpp" + +namespace PJ { + +/// Wraps a FilterTransformFactory in a PJ_filter_registry_t fat pointer so it +/// can be advertised through the standard service registry. Non-copyable; +/// must outlive the service registry it is published into. +class FilterRegistryHost { + public: + explicit FilterRegistryHost(sdk::FilterTransformFactory& factory) : factory_(&factory) { + vtable_.protocol_version = 1; + vtable_.struct_size = sizeof(PJ_filter_registry_vtable_t); + vtable_.register_transform = &thunkRegister; + vtable_.unregister_transform = &thunkUnregister; + vtable_.create_transform = &thunkCreate; + vtable_.lookup_deleter = &thunkLookupDeleter; + vtable_.list_ids = &thunkListIds; + } + FilterRegistryHost(const FilterRegistryHost&) = delete; + FilterRegistryHost& operator=(const FilterRegistryHost&) = delete; + FilterRegistryHost(FilterRegistryHost&&) = delete; + FilterRegistryHost& operator=(FilterRegistryHost&&) = delete; + ~FilterRegistryHost() = default; + + [[nodiscard]] PJ_filter_registry_t service() noexcept { + return PJ_filter_registry_t{static_cast(this), &vtable_}; + } + + [[nodiscard]] sdk::FilterTransformFactory& factory() noexcept { + return *factory_; + } + [[nodiscard]] const sdk::FilterTransformFactory& factory() const noexcept { + return *factory_; + } + + private: + static FilterRegistryHost& selfOf(void* ctx) noexcept { + return *static_cast(ctx); + } + + static void writeError(PJ_error_t* out, const char* msg) noexcept { + if (out == nullptr) { + return; + } + std::memset(out, 0, sizeof(*out)); + if (msg != nullptr) { + std::strncpy(out->message, msg, sizeof(out->message) - 1); + } + } + + /// The plugin hands us a raw void* aliasing whatever it wants pinned (the + /// libraryOwner of its DataSource/Toolbox handle). We need a shared_ptr + /// that releases when the entry is dropped — but the caller's shared_ptr + /// is on the plugin side. Trick: the View::registerRaw passes `library_owner.get()` + /// stripped of its control block; we accept that we can only pin by raw + /// pointer identity (the host's own factory_handles_ table holds the + /// shared_ptr it constructs internally from the original plugin handle + /// the host knows about). For the v1 cut, store the raw pointer as an + /// opaque token and rely on the host knowing how to map it back via the + /// PluginRuntimeCatalog's library handles. + /// + /// Concretely: the host caller is expected to call `setLibraryOwnerMap` + /// with a function that turns a void* token into a shared_ptr at + /// register time. If not set, registrations succeed but library_owner is + /// empty (test mode / in-process). + using LibraryOwnerResolver = std::function(void* token)>; + + public: + void setLibraryOwnerResolver(LibraryOwnerResolver resolver) { + std::lock_guard lock(mutex_); + resolver_ = std::move(resolver); + } + + private: + static bool thunkRegister( + void* ctx, PJ_filter_transform_registration_t reg, void* library_owner_token, PJ_error_t* out_error) noexcept { + auto& self = selfOf(ctx); + if (reg.factory == nullptr || reg.deleter == nullptr || reg.id.data == nullptr || reg.id.size == 0) { + writeError(out_error, "register_transform: null factory/deleter or empty id"); + return false; + } + std::string id(reg.id.data, reg.id.size); + auto factory_fn = reg.factory; + auto factory_ctx = reg.factory_ctx; + auto deleter_fn = reg.deleter; + + sdk::FilterTransformFactory::CreateFn create_wrapper = [factory_fn, factory_ctx]() { + auto* raw = factory_fn(factory_ctx); + return reinterpret_cast(raw); + }; + sdk::FilterTransformFactory::DeleterFn delete_wrapper = [deleter_fn](sdk::FilterTransform* p) noexcept { + deleter_fn(reinterpret_cast(p)); + }; + + std::shared_ptr owner; + { + std::lock_guard lock(self.mutex_); + if (self.resolver_ && library_owner_token != nullptr) { + owner = self.resolver_(library_owner_token); + } + } + self.factory_->registerTransform( + std::move(id), std::move(create_wrapper), std::move(delete_wrapper), std::move(owner)); + return true; + } + + static bool thunkUnregister(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept { + auto& self = selfOf(ctx); + if (id.data == nullptr || id.size == 0) { + writeError(out_error, "unregister_transform: empty id"); + return false; + } + self.factory_->unregisterTransform(std::string_view{id.data, id.size}); + return true; + } + + static PJ_filter_transform_t* thunkCreate(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept { + auto& self = selfOf(ctx); + if (id.data == nullptr || id.size == 0) { + writeError(out_error, "create_transform: empty id"); + return nullptr; + } + auto sp = self.factory_->create(std::string_view{id.data, id.size}); + if (!sp) { + writeError(out_error, "create_transform: id not registered"); + return nullptr; + } + // The cross-DSO contract owns the raw pointer by handle. Stash the + // shared_ptr so its deleter (which carries library_owner) survives until + // the caller releases the handle via the per-id deleter. Must route via + // catalogue() — the static map that thunkDeleteInstance erases from — + // because the per-id deleter handed back by thunkLookupDeleter is a free + // function and cannot capture `self`. An earlier draft of this file + // populated a per-instance map (self.live_instances_) that thunkDelete + // never erased from, leaking every instance forever. + auto* raw = sp.get(); + { + std::lock_guard lock(self.mutex_); + catalogue().emplace(raw, std::move(sp)); + } + return reinterpret_cast(raw); + } + + // The plugin asks for a deleter by id, then later invokes it on the raw + // pointer. We hand it a uniform host-side trampoline that releases the + // live_instances_ entry (which dispatches to the entry's real deleter + + // drops the library_owner ref). + static PJ_filter_transform_deleter_fn thunkLookupDeleter(void* /*ctx*/, PJ_string_view_t /*id*/) noexcept { + return &thunkDeleteInstance; + } + + static void thunkDeleteInstance(PJ_filter_transform_t* p) noexcept { + if (p == nullptr) { + return; + } + auto* raw = reinterpret_cast(p); + // The destruction happens here when the shared_ptr is dropped from + // live_instances_; that runs the factory entry's real deleter + drops + // the library_owner. We need access to `self` — but the deleter_fn + // signature can't carry context. Resolve via the static catalogue. + catalogue().erase(raw); + } + + using LiveMap = std::unordered_map>; + + // Process-global instance catalogue. The thunkDeleteInstance handler is + // referenced by raw function pointer (so plugins can call it after the + // FilterRegistryHost-owning host shuts down), so it cannot capture the + // FilterRegistryHost*. Routing through this static keeps the contract + // clean. Single host per process is the expected configuration. + static LiveMap& catalogue() { + static LiveMap inst; + return inst; + } + + static void thunkListIds(void* ctx, PJ_string_view_t* out_ids, size_t capacity, size_t* out_count) noexcept { + auto& self = selfOf(ctx); + const auto ids = self.factory_->registeredIds(); + if (out_count != nullptr) { + *out_count = ids.size(); + } + if (out_ids != nullptr) { + // The strings live in factory entries (whose `id` is std::string with + // stable storage between calls); hand back views into them. Caller must + // not retain past the next factory mutation. + std::lock_guard lock(self.mutex_); + self.cached_id_views_.clear(); + self.cached_id_views_.reserve(ids.size()); + for (const auto& s : ids) { + self.cached_id_views_.push_back(s); + } + size_t emitted = std::min(capacity, self.cached_id_views_.size()); + for (size_t i = 0; i < emitted; ++i) { + out_ids[i] = PJ_string_view_t{self.cached_id_views_[i].data(), self.cached_id_views_[i].size()}; + } + } + } + + private: + sdk::FilterTransformFactory* factory_; + PJ_filter_registry_vtable_t vtable_{}; + std::mutex mutex_; + LibraryOwnerResolver resolver_; + std::vector cached_id_views_; + // Live-instance bridge from raw FilterTransform* to the shared_ptr the + // factory minted is stored in the static catalogue() above, not here — the + // per-id deleter handed back by thunkLookupDeleter is a free function and + // can only reach the static. +}; + +} // namespace PJ diff --git a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp index 61a4bf0e..b5406b94 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp @@ -49,6 +49,7 @@ struct PluginDescriptor { std::vector encoding; ///< for message parsers (one or more) std::vector file_extensions; ///< for data sources std::vector capabilities; ///< optional capability tags + std::vector tags; ///< manifest `tags` — free-form labels (category, role flags like "plot_action", …) }; /// Diagnostic for a candidate DSO that could not produce a valid descriptor. diff --git a/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp index 09ce0e2e..a06c0d2e 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp @@ -56,6 +56,7 @@ struct RuntimeToolboxPlugin { std::string id; std::string version; uint64_t capabilities = 0; + std::vector tags; ///< from manifest `tags` (e.g. "plot_action" — host routes by role) std::filesystem::file_time_type loaded_mtime; }; diff --git a/pj_plugins/src/plugin_catalog.cpp b/pj_plugins/src/plugin_catalog.cpp index 82deac97..54b15fde 100644 --- a/pj_plugins/src/plugin_catalog.cpp +++ b/pj_plugins/src/plugin_catalog.cpp @@ -252,11 +252,16 @@ Expected decodeManifest( if (!capabilities) { return unexpected(capabilities.error()); } + auto tags = readStringArray(j, "tags"); + if (!tags) { + return unexpected(tags.error()); + } d.description = *description; d.category = *category; d.file_extensions = *file_extensions; d.capabilities = *capabilities; + d.tags = *tags; auto encoding = readStringArray(j, "encoding"); if (!encoding) { diff --git a/pj_plugins/src/plugin_runtime_catalog.cpp b/pj_plugins/src/plugin_runtime_catalog.cpp index 3c1d0db2..23884656 100644 --- a/pj_plugins/src/plugin_runtime_catalog.cpp +++ b/pj_plugins/src/plugin_runtime_catalog.cpp @@ -247,6 +247,7 @@ bool PluginRuntimeCatalog::loadAndRegisterToolbox(const PluginDescriptor& descri loaded.name = descriptor.name; loaded.version = descriptor.version; loaded.capabilities = loaded.library.createHandle().capabilities(); + loaded.tags = descriptor.tags; // Same fail-fast contract as DataSource above: kToolboxCapabilityHasDialog // requires an exported dialog vtable.