From 421ad1b335c10a4596e7f627dff008aed3f86ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 11 Jun 2026 03:11:23 +0200 Subject: [PATCH 1/4] feat(filter_protocol): FilterTransform SDK contract + surface manifest tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pj_plugins/filter_protocol/ — a header-only sub-module exposing the Strategy contract for Filter Editor transforms. The SDK ships only the abstract interface + registry; plugins provide the concrete strategies and register them with the factory at load time. Headers: - pj_plugins/sdk/filter_transform.hpp — abstract PJ::sdk::FilterTransform. Two streaming surfaces: calculateNextPoint (SISO, PJ3 mirror) and appendTail (chunked; default loops calculateNextPoint; override per-transform when running state makes it O(Δsamples)). Plus saveParams / loadParams JSON, and clone() for host hand-off across the plugin DSO boundary. - pj_plugins/sdk/filter_transform_factory.hpp — registry singleton, stable registration order, plus PJ_REGISTER_FILTER_TRANSFORM macro for auto-register at static init. Lift PJ::sdk::Point2 to its own header: - pj_base/point2.hpp — generic 2D vocab type (double x, double y). Was defined inside image_annotations.hpp; promote so the Filter Editor transform contract can reuse it without an artificial coupling to image annotations. image_annotations.hpp now just includes the new header. Surface manifest tags: - PluginDescriptor / RuntimeToolboxPlugin gain a tags field, parsed from the manifest's tags array. Lets the host route a generic action — e.g. the plot's right-click 'Apply Filter…' slot — to whichever installed toolbox declares the matching tag, instead of hardcoding a plugin id in the host. Empty for plugins whose manifest omits the array (backward compatible). The pj_filter_sdk INTERFACE target is declared inline in pj_plugins/CMakeLists.txt (alongside dialog_protocol). It's a header-only sub-module with one target, so a dedicated filter_protocol/CMakeLists.txt was overhead — kept the layout to match dialog_protocol/ for header organisation, but the build wiring is now where it belongs. Wires pj_filter_sdk into the plugin SDK umbrella so plugin authors get the contract transitively from plotjuggler_sdk::plugin_sdk. Additive surface — no existing header / struct / ABI changes (Point2's struct definition + ABI layout are unchanged; only its file location moved). Required release level when merged: MINOR. Version bump kept out of this PR for release coordination. --- .../pj_base/builtin/image_annotations.hpp | 9 +-- pj_base/include/pj_base/point2.hpp | 18 +++++ pj_plugins/CMakeLists.txt | 19 ++++- .../pj_plugins/sdk/filter_transform.hpp | 78 +++++++++++++++++++ .../sdk/filter_transform_factory.hpp | 78 +++++++++++++++++++ .../pj_plugins/host/plugin_catalog.hpp | 1 + .../host/plugin_runtime_catalog.hpp | 1 + pj_plugins/src/plugin_catalog.cpp | 5 ++ pj_plugins/src/plugin_runtime_catalog.cpp | 1 + 9 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 pj_base/include/pj_base/point2.hpp create mode 100644 pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform.hpp create mode 100644 pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform_factory.hpp 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..bf41d806 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -17,6 +17,23 @@ 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. +target_link_libraries(pj_filter_sdk INTERFACE 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 +46,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/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..a11a4828 --- /dev/null +++ b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform_factory.hpp @@ -0,0 +1,78 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +// Registry singleton for FilterTransform implementations. Plugins register +// their concrete classes at load time; the host looks them up by id. Order +// of registration is preserved (mirrors PJ3's dropdown order). + +#include +#include +#include +#include +#include +#include + +#include "pj_plugins/sdk/filter_transform.hpp" + +namespace PJ::sdk { + +class FilterTransformFactory { + public: + using CreateFn = std::function()>; + + [[nodiscard]] static FilterTransformFactory& instance() { + static FilterTransformFactory inst; + return inst; + } + + /// Re-registering an existing `id` replaces the previous factory entry. + void registerTransform(const char* id, CreateFn fn) { + for (auto& e : entries_) { + if (e.id == id) { + e.fn = std::move(fn); + return; + } + } + entries_.push_back({id, std::move(fn)}); + } + + [[nodiscard]] std::vector registeredIds() const { + std::vector ids; + ids.reserve(entries_.size()); + for (const auto& e : entries_) { + ids.push_back(e.id); + } + return ids; + } + + /// Returns nullptr if `id` is not registered. + [[nodiscard]] std::unique_ptr create(std::string_view id) const { + for (const auto& e : entries_) { + if (e.id == id) { + return e.fn(); + } + } + return nullptr; + } + + private: + struct Entry { + std::string id; + CreateFn fn; + }; + std::vector entries_; +}; + +} // namespace PJ::sdk + +/// Self-register `Class` at static-init. `Class{}` must be default-constructible +/// and its `id()` must be unique. +#define PJ_REGISTER_FILTER_TRANSFORM(Class) \ + namespace { \ + [[maybe_unused]] const bool _pj_register_##Class = ([] { \ + PJ::sdk::FilterTransformFactory::instance().registerTransform( \ + (Class){}.id(), [] { return std::unique_ptr(new (Class)()); }); \ + return true; \ + }(), true); \ + } 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. From c13f8780b5beb12a0a9fc8abe83319576936e0c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Fri, 12 Jun 2026 11:57:51 +0200 Subject: [PATCH 2/4] feat(filter_protocol): vendor the 12 builtin transforms in the SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Filter Editor plugin and the host (PJ4 app) need the same math to keep preview, layout-restore, and streaming render in lockstep. Until now each side had its own copy. This commit lifts the 12 concrete strategy classes plus the BinaryOp enum and the registerAllTransforms() factory hook into the SDK header so there is one source of truth. Plugins are dlopen'd RTLD_LOCAL, so each DSO still has its own factory singleton — what is unified is the code, not the runtime instances. JSON saveParams/loadParams remains the on-the-wire contract between plugin and host. Header is part of pj_filter_sdk; reaches plugin authors transitively via plotjuggler_sdk::plugin_sdk (no CMake changes needed). --- .../pj_plugins/sdk/builtin_transforms.hpp | 827 ++++++++++++++++++ 1 file changed, 827 insertions(+) create mode 100644 pj_plugins/filter_protocol/include/pj_plugins/sdk/builtin_transforms.hpp 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..3bc2fe06 --- /dev/null +++ b/pj_plugins/filter_protocol/include/pj_plugins/sdk/builtin_transforms.hpp @@ -0,0 +1,827 @@ +#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: call registerAllTransforms() once per DSO (idempotent). Both +// the plugin and the host call it to populate their own factory instances — +// the singletons are per-DSO because plugins are dlopen'd RTLD_LOCAL, so +// neither side ever sees the other's instances; only the JSON wire format +// (saved params) crosses the boundary. + +#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" +#include "pj_plugins/sdk/filter_transform_factory.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_; +}; + +// --------------------------------------------------------------------------- +// Registration — all transforms in display order (mirrors PJ3 dropdown order) +// --------------------------------------------------------------------------- + +// Idempotent: caller can invoke this on every build path without worry. Each +// DSO has its own factory instance and its own `registered` guard. +inline void registerAllTransforms() { + auto& f = FilterTransformFactory::instance(); + static bool registered = false; + if (registered) { + return; + } + registered = true; + + f.registerTransform(NoneTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(AbsoluteTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(ScaleTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(DerivativeTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(IntegralTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(MovingAverageTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(MovingRMSTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(MovingVarianceTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(OutlierRemovalTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(SamplesCounterTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(BinaryFilterTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(TimeSincePreviousTransform{}.id(), [] { return std::make_unique(); }); +} + +} // namespace PJ::sdk From 2342d2bdc057f367662776a90608cb707d6c9c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Fri, 12 Jun 2026 12:24:53 +0200 Subject: [PATCH 3/4] feat(filter_protocol): expose FilterTransformFactory as a host service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the pj.filter_registry.v1 host service so the plugin can register its FilterTransform classes into the host's single FilterTransformFactory during loaderInit and resolve them by id from the same registry — preview, layout restore, and the streaming read path all go through the same instances now. - filter_registry_abi.h: 5-slot C ABI vtable + service id + min-size pin. registration carries paired (factory_fn, deleter_fn) so destruction happens in the DSO that did the new; library_owner shared_ptr pins that DSO for the entry's life. - filter_registry_service.hpp: typed View + Traits. registerTransform() generates the trampolines from a C++ class. - filter_transform_factory.hpp: drop the global static singleton. Each entry now carries (create_fn, delete_fn, library_owner shared_ptr). create() returns shared_ptr whose deleter pins owner. - builtin_transforms.hpp: drop registerAllTransforms() helper — the plugin walks the catalogue itself in loaderInit now. - include/pj_plugins/host/filter_registry_host.hpp: C-ABI adapter that wraps an in-process FilterTransformFactory as the service ctx. Trampolines bridge the C ABI to the C++ factory; an optional LibraryOwnerResolver bridges plugin-side void* tokens to host-side shared_ptr. pj_filter_sdk now links pj_base for the C ABI primitives. --- pj_plugins/CMakeLists.txt | 6 +- .../pj_plugins/sdk/builtin_transforms.hpp | 40 +-- .../pj_plugins/sdk/filter_registry_abi.h | 131 ++++++++++ .../sdk/filter_registry_service.hpp | 158 ++++++++++++ .../sdk/filter_transform_factory.hpp | 93 +++++-- .../pj_plugins/host/filter_registry_host.hpp | 242 ++++++++++++++++++ 6 files changed, 606 insertions(+), 64 deletions(-) create mode 100644 pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_registry_abi.h create mode 100644 pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_registry_service.hpp create mode 100644 pj_plugins/include/pj_plugins/host/filter_registry_host.hpp diff --git a/pj_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index bf41d806..32859e98 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -27,8 +27,10 @@ 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. -target_link_libraries(pj_filter_sdk INTERFACE nlohmann_json::nlohmann_json) +# 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) 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 index 3bc2fe06..05d06f22 100644 --- a/pj_plugins/filter_protocol/include/pj_plugins/sdk/builtin_transforms.hpp +++ b/pj_plugins/filter_protocol/include/pj_plugins/sdk/builtin_transforms.hpp @@ -12,11 +12,12 @@ // itself via saveParams() / loadParams(), and clones via clone(). Pure C++20 — // no Qt, no Lua, no datastore — fully unit-testable in isolation. // -// Registration: call registerAllTransforms() once per DSO (idempotent). Both -// the plugin and the host call it to populate their own factory instances — -// the singletons are per-DSO because plugins are dlopen'd RTLD_LOCAL, so -// neither side ever sees the other's instances; only the JSON wire format -// (saved params) crosses the boundary. +// 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 @@ -42,7 +43,6 @@ #endif #include "pj_plugins/sdk/filter_transform.hpp" -#include "pj_plugins/sdk/filter_transform_factory.hpp" namespace PJ::sdk { @@ -796,32 +796,4 @@ class TimeSincePreviousTransform : public FilterTransform { std::optional prev_t_; }; -// --------------------------------------------------------------------------- -// Registration — all transforms in display order (mirrors PJ3 dropdown order) -// --------------------------------------------------------------------------- - -// Idempotent: caller can invoke this on every build path without worry. Each -// DSO has its own factory instance and its own `registered` guard. -inline void registerAllTransforms() { - auto& f = FilterTransformFactory::instance(); - static bool registered = false; - if (registered) { - return; - } - registered = true; - - f.registerTransform(NoneTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(AbsoluteTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(ScaleTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(DerivativeTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(IntegralTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(MovingAverageTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(MovingRMSTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(MovingVarianceTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(OutlierRemovalTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(SamplesCounterTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(BinaryFilterTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(TimeSincePreviousTransform{}.id(), [] { return std::make_unique(); }); -} - } // 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..4e9af904 --- /dev/null +++ b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_registry_abi.h @@ -0,0 +1,131 @@ +/** + * @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..4f988da7 --- /dev/null +++ b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_registry_service.hpp @@ -0,0 +1,158 @@ +#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*) -> 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_factory.hpp b/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform_factory.hpp index a11a4828..a33cd064 100644 --- 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 @@ -2,9 +2,15 @@ // SPDX-License-Identifier: MPL-2.0 #pragma once -// Registry singleton for FilterTransform implementations. Plugins register -// their concrete classes at load time; the host looks them up by id. Order -// of registration is preserved (mirrors PJ3's dropdown order). +// 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 @@ -19,22 +25,37 @@ namespace PJ::sdk { class FilterTransformFactory { public: - using CreateFn = std::function()>; + using CreateFn = std::function; + using DeleterFn = std::function; - [[nodiscard]] static FilterTransformFactory& instance() { - static FilterTransformFactory inst; - return inst; - } - - /// Re-registering an existing `id` replaces the previous factory entry. - void registerTransform(const char* id, CreateFn fn) { + /// 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.fn = std::move(fn); + 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; } } - entries_.push_back({id, std::move(fn)}); } [[nodiscard]] std::vector registeredIds() const { @@ -46,33 +67,49 @@ class FilterTransformFactory { return ids; } - /// Returns nullptr if `id` is not registered. - [[nodiscard]] std::unique_ptr create(std::string_view id) const { + /// 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) { - return e.fn(); + 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) noexcept { + 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 fn; + CreateFn create_fn; + DeleterFn delete_fn; + std::shared_ptr library_owner; }; std::vector entries_; }; } // namespace PJ::sdk - -/// Self-register `Class` at static-init. `Class{}` must be default-constructible -/// and its `id()` must be unique. -#define PJ_REGISTER_FILTER_TRANSFORM(Class) \ - namespace { \ - [[maybe_unused]] const bool _pj_register_##Class = ([] { \ - PJ::sdk::FilterTransformFactory::instance().registerTransform( \ - (Class){}.id(), [] { return std::unique_ptr(new (Class)()); }); \ - return true; \ - }(), true); \ - } 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..a57295b4 --- /dev/null +++ b/pj_plugins/include/pj_plugins/host/filter_registry_host.hpp @@ -0,0 +1,242 @@ +// 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. + auto* raw = sp.get(); + { + std::lock_guard lock(self.mutex_); + self.live_instances_.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_; + // Bridges from raw FilterTransform* (what crosses the C ABI) back to the + // shared_ptr the factory minted (its deleter carries the + // library_owner). When the plugin frees the handle, we drop the shared_ptr + // — that runs the deleter and the DSO ref drops. + std::unordered_map> live_instances_; +}; + +} // namespace PJ From 52092e3949ea1c1e9d6a032730aa95478afe8b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Fri, 12 Jun 2026 13:04:13 +0200 Subject: [PATCH 4/4] fix(filter_protocol): noexcept correctness for std::function + C ABI casts Two compile errors in the previous commit: 1. std::function is ill-formed under C++20: std::function does not specialise on noexcept function types. Drop the qualifier; the no-throw contract is enforced at the C ABI boundary instead (PJ_filter_transform_deleter_fn is still noexcept). 2. The View's generated factory lambda did not declare noexcept, so the conversion to PJ_filter_transform_factory_fn (a noexcept function pointer) failed under conformant ABIs. Add the qualifier. Verified with a g++ -std=c++20 smoke-test round-trip: registerHost -> service fat pointer -> View::registerTransform -> View::create -> loadParams -> calculateNextPoint returns the expected value. --- .../include/pj_plugins/sdk/filter_registry_service.hpp | 2 +- .../include/pj_plugins/sdk/filter_transform_factory.hpp | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) 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 index 4f988da7..f68b912d 100644 --- 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 @@ -48,7 +48,7 @@ class FilterRegistryView { 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*) -> PJ_filter_transform_t* { + auto* factory = +[](void*) noexcept -> PJ_filter_transform_t* { return reinterpret_cast(new Class{}); }; auto* deleter = +[](PJ_filter_transform_t* p) noexcept { 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 index a33cd064..daca390b 100644 --- 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 @@ -26,7 +26,11 @@ namespace PJ::sdk { class FilterTransformFactory { public: using CreateFn = std::function; - using DeleterFn = 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 @@ -81,7 +85,7 @@ class FilterTransformFactory { } auto deleter = e.delete_fn; auto owner = e.library_owner; - return std::shared_ptr(raw, [deleter, owner](FilterTransform* p) noexcept { + return std::shared_ptr(raw, [deleter, owner](FilterTransform* p) { deleter(p); (void)owner; // owner ref drops here, after deleter — keeps DSO loaded });