feat(plugin-sdk): add video packet types, source node support, and host-side tick loop#238
Conversation
…st-side tick loop Phase 1 — Video packet types in C ABI: - Add CPixelFormat enum (Rgba8/I420/Nv12) and CRawVideoFormat struct - Extend CPacketType with RawVideo (8) and EncodedVideo (9) discriminants - Add raw_video_format field to CPacketTypeInfo - Implement bidirectional conversions for video types in conversions.rs - Update native_plugin_entry! macro to emit proper video pin metadata - Bump NATIVE_PLUGIN_API_VERSION from 2 to 3 Phase 2 — Source node trait and macro in SDK: - Add SourceConfig struct with from_fps() and from_interval_us() helpers - Add NativeSourceNode trait (metadata, source_config, new, tick, update_params, cleanup) - Add native_source_plugin_entry! macro generating tick/get_source_config FFI exports and stub process_packet - Add CSourceConfig and CTickResult types with convenience constructors - Extend CNativePluginAPI with Option<fn> fields for get_source_config and tick (backward-compatible v3 additions) Phase 3 — Host-side wrapper for source plugins: - Add is_source, tick_interval_us, max_ticks to PluginMetadata - Detect source capability during plugin load via temporary instance probe - Split NativeNodeWrapper::run() into run_processor() (existing) and run_source() (new tick-driven loop) - Implement Ready → Start → Running → tick → Stopped lifecycle - Add apply_params_update() helper for FFI parameter serialization - Handle empty inputs for source plugins in register_plugins() Closes #169 Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| if let Some(get_source_config) = api.get_source_config { | ||
| // Create a temporary instance with no params to query source config | ||
| let temp_handle = (api.create_instance)( | ||
| std::ptr::null(), | ||
| plugin_log_callback_noop, | ||
| std::ptr::null_mut(), | ||
| ); | ||
| if !temp_handle.is_null() { | ||
| let cfg = get_source_config(temp_handle); | ||
| if cfg.is_source { | ||
| metadata.is_source = true; | ||
| metadata.tick_interval_us = cfg.tick_interval_us; | ||
| metadata.max_ticks = cfg.max_ticks; | ||
| info!( | ||
| kind = %metadata.kind, | ||
| tick_interval_us = cfg.tick_interval_us, | ||
| max_ticks = cfg.max_ticks, | ||
| "Detected source plugin" | ||
| ); | ||
| } | ||
| (api.destroy_instance)(temp_handle); | ||
| } |
There was a problem hiding this comment.
🚩 Source config probed with no-params instance may diverge from actual runtime config
During plugin loading at crates/plugin-native/src/lib.rs:122-144, a temporary instance is created with null params to probe get_source_config. The tick_interval_us and max_ticks values are cached in PluginMetadata and used later by run_source at crates/plugin-native/src/wrapper.rs:648-649. If a source plugin's tick interval depends on constructor parameters (e.g., a configurable frame rate), the probed values will be stale because the real instance is created later with actual params via create_node. The NativeSourceNode::source_config(&self) method signature allows per-instance config, but the host only reads it once at load time from the no-params probe. This is a documented design choice ("If instance creation fails we fall back to treating it as a processor plugin") but could surprise plugin authors whose tick rate depends on params.
Was this helpful? React with 👍 or 👎 to provide feedback.
Debug
There was a problem hiding this comment.
Valid point. This is a deliberate design trade-off documented in the code — source plugins must support parameterless construction for the probe to work. The alternative (a static API query without instance creation) would require adding another function pointer to CNativePluginAPI and changing the macro contract. For now, the fallback behavior (treating the plugin as a processor) is safe, and the NativeSourceNode trait's new() signature already takes Option<serde_json::Value>, so well-behaved source plugins will handle None params gracefully.
If this becomes a friction point for plugin authors, we can add a static get_source_config variant (no instance required) in a future API revision.
There was a problem hiding this comment.
Good refinement of the earlier point. You're right that the probed tick_interval_us/max_ticks could diverge from the real instance's config if the source plugin adjusts tick rate based on params.
In practice this is acceptable for now — the probe establishes that the plugin is a source and provides reasonable defaults. If we later need per-instance tick config, the fix is straightforward: query get_source_config again on the real instance inside run_source() after create_instance is called with actual params, and use those values instead of the metadata cache. That would be a small additive change in wrapper.rs.
…ointer - Replace inner tokio::select! loop with try_recv() for non-blocking control message drain (fixes unreachable else branch that would hang source plugins indefinitely) - Move tick sleep from spawn_blocking to tokio::time::sleep with cancellation-aware select! (avoids blocking thread pool, enables responsive shutdown) - Add tokio 'time' feature to plugin-native Cargo.toml - Fix dangling pointer in packet_type_to_c: return null pointers in CPacketTypeInfo, document caller must patch after storing owned value - Remove unused mut on control_channel_open in Ready→Start loop Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
…cycle - Replace tokio::time::sleep with tokio::time::interval + MissedTickBehavior::Skip to prevent accumulated drift in tick loop - Remove hardcoded Vp9 from EncodedVideo inbound conversion; map to Binary until CVideoCodec enum is added (TODO left in code) - Move tick_count increment before error/done checks so it always reflects actual ticks executed - Emit Stopped state on all pre-start exit paths (cancellation, shutdown, channel close) for consistent lifecycle reporting - Remove unnecessary control_channel_open variable from Ready→Start loop - Add warning log when source config probe fails (null from create_instance with no params) - Add #[must_use] on CPacketTypeOwned to prevent silent drops - Remove unused _context param from apply_params_update - Remove unused EncodedVideoFormat import Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
…t close - Clamp tick_interval_us to at least 1µs to prevent tokio::time::interval panic when a plugin returns 0. - Break the outer tick loop when output channel closes, since source nodes lack the input-close backstop that processor nodes have. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Port the Slint renderer from PR #237 into a standalone native plugin using the NativeSourceNode trait from PR #238. This keeps slint and slint-interpreter as plugin-only dependencies, avoiding committing them to the core workspace. The plugin renders .slint files to RGBA8 video frames at configurable resolution and frame rate. All Slint operations run on a shared dedicated thread with channel-based message passing to handle the non-Send constraint of Slint types. Includes: - Plugin crate with config, shared thread, and NativeSourceNode impl - justfile build/lint/fix/copy targets - CI format check and clippy in lint-simple job - Marketplace metadata (plugin.yml) and regenerated official-plugins.json - Docs reference page and updated plugin index (9 -> 10) - Sample .slint files (watermark, scoreboard, lower_third) - Sample oneshot pipeline using plugin::native::slint Signed-off-by: Devin AI <devin@cognition.ai> Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
* feat: add native Slint UI plugin Port the Slint renderer from PR #237 into a standalone native plugin using the NativeSourceNode trait from PR #238. This keeps slint and slint-interpreter as plugin-only dependencies, avoiding committing them to the core workspace. The plugin renders .slint files to RGBA8 video frames at configurable resolution and frame rate. All Slint operations run on a shared dedicated thread with channel-based message passing to handle the non-Send constraint of Slint types. Includes: - Plugin crate with config, shared thread, and NativeSourceNode impl - justfile build/lint/fix/copy targets - CI format check and clippy in lint-simple job - Marketplace metadata (plugin.yml) and regenerated official-plugins.json - Docs reference page and updated plugin index (9 -> 10) - Sample .slint files (watermark, scoreboard, lower_third) - Sample oneshot pipeline using plugin::native::slint Signed-off-by: Devin AI <devin@cognition.ai> Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix: port sample pipelines from PR #237 with plugin node kind Replace simplified steps-based oneshot pipeline with the full compositor pipeline from PR #237 (colorbars + watermark overlay → compositor → VP9 → WebM → http_output). Add the missing dynamic pipeline (scoreboard + lower-third overlays composited onto colorbars, streamed via MoQ). Update docs example to match the correct node-based format. All pipelines use plugin::native::slint instead of video::slint. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix: rename output pin to 'out' and add fontconfig dep to marketplace CI - Rename plugin output pin from 'video' to 'out' to match the convention used by all built-in nodes. The pipeline compiler's 'needs' syntax defaults from_pin to 'out', so using a different name broke graph wiring. - Add libfontconfig1-dev to marketplace-build.yml system deps so the Slint plugin can be compiled by build_official_plugins.sh. - Update docs to reflect the pin name change. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix: support parameterless construction for source config probe The host probes source plugins by calling create_instance with no params to read source_config(). The Slint plugin requires slint_file which fails validation with default config. Return a lightweight probe-only instance when params is None so the host correctly detects is_source=true and uses run_source() instead of run_processor(). Without this fix the watermark node was treated as a processor, crashed immediately (no input pins), and the compositor produced output with only colorbars (no overlay). Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix(plugin/slint): address review findings — scope guard, clone, naming, validation - Add ClearWindow scope guard so CURRENT_WINDOW thread-local is cleared even if definition.create() or component.show() fails via early return - Remove unnecessary data.clone() in static-UI render path — store first, then clone from cache (saves ~3.7 MB allocation per cache-miss render) - Rename lib from slint_plugin to slint, matching the naming convention of all other native plugins (whisper, kokoro, vad, etc.) - Add MAX_DIMENSION (7680 / 8K) upper bound on width/height to guard against config typos that would attempt multi-GB buffer allocations - Reject absolute paths in validate_slint_asset_path() as defense-in-depth - Document update_timers_and_animations() idempotency for multi-instance usage on the shared Slint thread Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix(compositor): frame-aligned sync in oneshot mode prevents asymmetric drain Cherry-picked from PR #238 branch (84364c6). In oneshot mode, verify all active slots have pending frames before dequeuing any, so fast sources aren't consumed ahead of slower ones. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix(plugin-native): re-query source config from live instance for per-instance max_ticks The load-time probe creates a default instance (null params) to detect source plugins, so max_ticks is always 0 (infinite). Per-instance params like frame_count: 300 were never applied to the tick loop limit. Now run_source() re-queries get_source_config() on the live instance after it's created with actual params, so the tick loop respects the configured frame_count. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * style: format wrapper.rs Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix(plugin-native): use if-let instead of match for clippy single_match_else Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix(plugin-native): avoid begin_call() leak when get_source_config is None Split the tuple pattern into chained and_then/map so begin_call() is only invoked when get_source_config is Some, preventing an in_flight_calls counter leak in the else branch. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * improve graphics * feat(client): add declarative overlay controls in client section (#240) * feat(client): add declarative overlay controls in client section Add a `controls` array to `ClientSection` so pipeline authors can declare interactive widgets (toggle, text, number, button) targeting specific node properties. The StreamView renders these controls automatically when a session is active, sending `TuneNodeAsync` / `UpdateParams` messages on interaction. Rust changes: - New `ControlType` enum and `ControlConfig` struct in `yaml.rs` - Extend `ClientSection` with `controls: Option<Vec<ControlConfig>>` - Add `name` field to `NodeInfo` for node-name validation - Lint rules: `control-unknown-node`, `control-number-no-bounds` - Register new types for TypeScript generation UI changes: - New `OverlayControls` component with toggle/text/number/button widgets - New `buildParamUpdate()` utility for dot-notation → nested JSON - Integrate `OverlayControls` into `StreamView` Sample: - Add controls to `video_moq_slint_scoreboard.yml` for scoreboard and lower-third properties Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix(controls): stabilize debounce/throttle with ref pattern, add useTuneNode hook Address Devin Review feedback: 1. TextControl: store onSend in a ref so the debounce closure is stable across re-renders. Pending timers now always call the latest callback instead of leaking stale intermediate values. 2. NumberControl: apply the same ref pattern for onSend in the throttle closure for consistency, preventing similar issues. 3. Extract useTuneNode hook from useSession — a lightweight hook that only provides tuneNodeConfig without subscribing to pipeline or connection state, avoiding unnecessary re-renders in OverlayControls. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix(controls): address review feedback — dead code, guards, stale state, docs 1. Remove dead isDraggingRef in NumberControl (written but never read). 2. Guard buildParamUpdate against empty/malformed paths — filter empty segments, throw on zero valid segments. Add unit tests covering single/multi-segment, empty, dot-only, double-dot, and leading/ trailing dot paths. 3. Fix ToggleControl stale checked on rapid double-click — use functional updater (setChecked(prev => ...)) with onSendRef pattern matching TextControl/NumberControl. 4. Update misleading 'stable' comment on makeSend to accurately describe that a new closure is created per render but child controls absorb this via onSendRef. 5. Move getWebSocketService() to module scope in useTuneNode since it returns a singleton — avoids a new reference on every render and keeps tuneNodeConfig deps minimal (only sessionId). 6. Document that ControlConfig.default is a UI-only hint — it seeds local widget state but is not sent to the server on mount. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix(controls): deep-merge partial nested updates in useTuneNode useTuneNode now reads the current nodeParamsAtom state and deep-merges the partial config before writing, so sibling nested properties are preserved. Previously, two controls targeting the same node but different nested paths (e.g. properties.home_score and properties.away_score) would clobber each other because writeNodeParams does a shallow top-level merge. Add deepMerge utility to controlProps.ts — recursively merges plain objects, replaces arrays and primitives wholesale. Includes 8 unit tests covering nested merge, array replacement, type transitions, successive merges, and immutability. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * test(e2e): add Playwright test for overlay controls Add overlay-controls.spec.ts exercising all four control types (toggle, text, number, button). The test: 1. Selects the new 'Test: Overlay Controls' sample pipeline (colorbars → sink, no plugins/MoQ required). 2. Creates a session and verifies the Pipeline Controls section renders with correct labels and group headings. 3. Exercises each control type and asserts the correct TuneNodeAsync / UpdateParams WebSocket payload is sent: - Toggle: sends { draw_time: false } - Text: sends { label: "World" } after 300ms debounce - Slider: sends { properties: { width: 800 } } (dot-notation path) - Button: sends { reset: true } 4. Asserts no unexpected console errors. 5. Destroys the session and cleans up. Also adds data-testid='overlay-controls' to the OverlayControls component for scoped locators, and fixes a TS6 useRef() arity error. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> --------- Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-authored-by: StreamKit Devin <devin@streamkit.dev> Co-authored-by: Claudio Costa <cstcld91@gmail.com> * fix(lint): include controls in mode-mismatch-oneshot check The `controls` field is dynamic-only but was not included in the `has_dynamic_fields` check in `lint_client_section`, so a oneshot pipeline with a `controls` section would not trigger the `mode-mismatch-oneshot` warning. Add it to the check and update the warning message and doc comment accordingly. Adds a unit test verifying the warning fires for controls-only oneshot pipelines. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <cstcld91@gmail.com> --------- Signed-off-by: Devin AI <devin@cognition.ai> Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-authored-by: StreamKit Devin <devin@streamkit.dev> Co-authored-by: Claudio Costa <cstcld91@gmail.com> Co-authored-by: staging-devin-ai-integration[bot] <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Summary
Implements Phases 1–3 of the native plugin SDK infrastructure to support video output pins with proper pixel format metadata and source nodes (zero inputs, tick-driven output). This is groundwork for turning nodes like the Slint renderer into standalone plugins.
Phase 1 — Video packet types in C ABI (addresses #169):
CPixelFormatenum (Rgba8/I420/Nv12) andCRawVideoFormatstructCPacketType::RawVideo(8) andCPacketType::EncodedVideo(9) discriminantsraw_video_formatfield onCPacketTypeInfopixel_format_to_c/from_c,raw_video_format_to_c/from_c, video frame ↔CPacket)native_plugin_entry!macro updated to emit proper video pin metadata instead of mapping toBinaryNATIVE_PLUGIN_API_VERSIONbumped from 2 → 3CPacketType::EncodedVideodiscriminant exists for forward-compat, but inbound conversion maps toBinaryuntil aCVideoCodecenum is added (TODO in code)Phase 2 — Source node trait and macro in SDK:
SourceConfigstruct withfrom_fps()/from_interval_us()helpersNativeSourceNodetrait (metadata,source_config,new,tick,update_params,cleanup)native_source_plugin_entry!macro generatingtick/get_source_configFFI exports and a stubprocess_packetCSourceConfigandCTickResultC ABI types with convenience constructorsCNativePluginAPIextended withOption<fn>fields forget_source_configandtick(backward-compatible v3)Phase 3 — Host-side wrapper for source plugins:
PluginMetadatagainsis_source,tick_interval_us,max_ticksload()via temporary instance probe withget_source_config(warns if probe fails)NativeNodeWrapper::run()split intorun_processor()(existing input-driven loop) andrun_source()(new tick-driven loop)run_source()implementsInitializing → Ready → Start → Running → tick → Stoppedlifecycletokio::time::intervalwithMissedTickBehavior::Skip(matching existing video nodes)tick_interval_usclamped to 1µs to preventtokio::time::intervalpanictokio::select!Stoppedstate for consistent lifecycleregister_plugins()handles empty inputs for source pluginsCompositor fix — frame-aligned sync in oneshot mode:
is_empty()before dequeuing, ensuring frame-aligned consumption regardless of production rate differencesCloses #169
Review & Testing Checklist for Human
video_colorbars.yml). The change only affects multi-source oneshot compositing — single-source should behave identically since all slots trivially have frames.CPacketTypeInfolayout withraw_video_formatfield — ensure no padding/alignment issues across compilers. Check that the v3Optionfields inCNativePluginAPIare ABI-safe (nullable function pointers).run_source()implementation assumesNodeControlMessage::Startis sent after all downstream nodes are ready.PacketType::RawVideo(RawVideoFormat { width: Some(1920), height: Some(1080), pixel_format: Rgba8 })correctly preserves metadata through the C ABI boundary.get_source_config/tick) against the v3 host and verify it still works as a processor.Suggested test plan:
video_colorbars.ymloneshot pipeline — should produce exactly 300 frames as before.video_slint_watermark.yml(from PR feat(nodes): extract Slint overlay into standalone video::slint node #237) — should produce exactly 300 composited frames and terminate cleanly, with no extra black+overlay frames.native_source_plugin_entry!, build, load, and verify tick loop runs correctly.Notes
just lintandjust testboth pass clean (all 68 compositor tests pass).native_plugin_entry!andnative_source_plugin_entry!(~530 lines shared metadata logic) is a known fast-follow to extract into a helper macro.Link to Devin session: https://staging.itsdev.in/sessions/41c6ad828d354ab8a388cfe3cbe6475f
Requested by: @streamer45