Skip to content

feat: add native Slint UI plugin#239

Merged
streamer45 merged 13 commits intomainfrom
devin/1775161780-slint-native-plugin
Apr 3, 2026
Merged

feat: add native Slint UI plugin#239
streamer45 merged 13 commits intomainfrom
devin/1775161780-slint-native-plugin

Conversation

@staging-devin-ai-integration
Copy link
Copy Markdown
Contributor

@staging-devin-ai-integration staging-devin-ai-integration bot commented Apr 2, 2026

Summary

Native Slint UI plugin for StreamKit — renders .slint UI definitions as RGBA video frames for overlay compositing. Built on the native plugin SDK (PR #238) with source-node tick-driven rendering.

Core plugin (plugins/native/slint/)

  • SlintThread: Dedicated !Send thread managing Slint instances via channels; supports multi-instance rendering with shared event loop
  • Config: Validates slint file paths (rejects .. traversal and absolute paths), dimensions (1–7680 MAX_DIMENSION cap), FPS, keyframe intervals, and frame_count for finite pipelines
  • Static UI caching: When static_ui: true, caches rendered frames and only re-renders on keyframe property changes
  • Property keyframes: Cycle through property sets at configurable intervals for animated overlays

Host-side fixes

  • Compositor frame-sync (compositor/mod.rs): Two-pass dequeue in oneshot mode ensures all active slots have pending frames before dequeueing any — prevents fast sources (colorbars) draining ahead of slower ones (slint)
  • Per-instance max_ticks (wrapper.rs): Re-queries get_source_config() on the live instance after creation with actual params, so frame_count: 300 correctly limits the tick loop instead of always using the load-time probe default (0 = infinite)
  • begin_call() leak fix (wrapper.rs): Refactored from tuple pattern to chained and_then/map so begin_call() is only invoked when get_source_config is Some, preventing an in_flight_calls counter leak

Lint fix

  • mode-mismatch-oneshot now includes controls: The has_dynamic_fields check was missing controls, so oneshot pipelines with a controls section would silently pass. Fixed with test coverage.

Review findings addressed

  1. CURRENT_WINDOW scope guard (Drop impl for cleanup on early return)
  2. Removed unnecessary data.clone() in static-UI path (saves ~3.7 MB per cache-miss)
  3. Renamed lib from slint_pluginslint (matches convention)
  4. MAX_DIMENSION upper bound (7680) on width/height
  5. is_absolute() check on path validation (defense-in-depth)
  6. Documented update_timers_and_animations() idempotency
  7. begin_call() leak prevention via and_then/map chaining
  8. controls added to has_dynamic_fields for mode-mismatch-oneshot lint

Review & Testing Checklist for Human

  • Oneshot pipeline termination: Run curl -X POST http://localhost:4545/api/v1/process -F "config=@samples/pipelines/oneshot/video_slint_watermark.yml" -o /tmp/output.webm — should terminate in ~10s with 300 frames
  • Output playback: Play /tmp/output.webm — should show colorbars background with Slint watermark overlay, no trailing black+overlay frames
  • Compositor frame-sync: Verify the two-pass dequeue logic in compositor/mod.rs doesn't regress existing oneshot tests (just test passes all compositor tests)
  • Plugin naming: Confirm libslint.so is produced (not libslint_plugin.so) and all references are consistent across plugin.yml, official-plugins.json, justfile, docs
  • Lint rule coverage: Try a YAML oneshot pipeline with a controls section and verify the mode-mismatch-oneshot warning fires

Notes

  • The compositor frame-sync fix was cherry-picked from PR feat(plugin-sdk): add video packet types, source node support, and host-side tick loop #238's branch (commit 84364c6) since it wasn't included in the merge to main
  • The max_ticks fix is in the host-side wrapper (crates/plugin-native/), not the plugin itself — it affects all source plugins that set max_ticks based on runtime params
  • PORTABILITY_REVIEW.md is referenced in AGENTS.md but was deleted in commit 9548c98 — this is a pre-existing gap, consistent with other recent plugin additions
  • lint_client_against_nodes (Rules 21-22 for controls) is only called from unit tests currently — no production caller yet; integration is planned
  • Module-level getWebSocketService() in useTuneNode.ts is safe: the constructor only stores the URL, no connection is established until explicit connect() call

Link to Devin session: https://staging.itsdev.in/sessions/87676781c3744d4e8dbd36f39422b350
Requested by: @streamer45


Staging: Open in Devin

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>
@staging-devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Staging: Open in Devin
Debug

Playground

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>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

🐛 1 issue in files not directly in the diff

🐛 Missing libfontconfig1-dev in marketplace-build.yml breaks plugin CI build (.github/workflows/marketplace-build.yml:45)

The Slint plugin depends on yeslogic-fontconfig-sys (confirmed in plugins/native/slint/Cargo.lock:1280), which requires the libfontconfig1-dev system package to compile. The PR correctly adds this dependency to .github/workflows/plugins.yml:72 for lint CI, but does not add it to .github/workflows/marketplace-build.yml:45 which installs system dependencies for building all official plugins. The build_official_plugins.sh script auto-discovers plugins from marketplace/official-plugins.json and builds them all, so it will attempt to build the Slint plugin and fail with a missing fontconfig header error. This violates the AGENTS.md checklist item: "Build prerequisites in .github/workflows/release.yml if new system deps are required to compile or package the plugin."

View 6 additional findings in Devin Review.

Staging: Open in Devin
Debug

Playground

streamkit-devin and others added 2 commits April 2, 2026 21:05
- 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>
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>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 7 additional findings in Devin Review.

Staging: Open in Devin
Debug

Playground

Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 PORTABILITY_REVIEW.md referenced in AGENTS.md but deleted from repo

AGENTS.md requires updating marketplace/PORTABILITY_REVIEW.md when adding an official plugin, but the file was deleted in commit 9548c98 and no longer exists in the repository. This is a pre-existing gap — this PR is consistent with other recent plugin additions (supertonic, helsinki) that also didn't update it. The AGENTS.md checklist should either be updated to remove this requirement or the file should be restored.

Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

@staging-devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Slint Native Plugin — E2E Test Report

Built the plugin locally, loaded it into a running StreamKit server, and tested the oneshot watermark pipeline end-to-end via curl + ffprobe + pixel comparison.

All 4 tests passed.

Test Results
Test Result Details
1. Plugin API Registration Passed kind: "plugin::native::slint", output pin "out", pixel_format: "Rgba8", slint_file required
2. Oneshot Watermark Pipeline Passed HTTP 200, 455KB WebM, VP9 1280x720@30fps
3. Watermark Overlay Differential Passed 100% pixel diff in overlay region (1080,20 180x44) — watermark IS composited
4. Error Handling Passed Invalid slint_file → HTTP 500 with descriptive error, server stays healthy
Bugs Found & Fixed (prior to this run)
  1. Output pin naming (videoout): broke needs: wiring convention
  2. Source config probe failure: parameterless create_instance(null) failed validation → plugin treated as processor instead of source
  3. Missing CI dependency: libfontconfig1-dev absent from marketplace-build.yml
Watermark Overlay Evidence (Test 3)

Extracted frame 5 from both colorbars-only and watermark pipelines, cropped the overlay region (x:1080, y:20, w:180, h:44), and compared pixel data.

Colorbars Only (frame 5) With Watermark Overlay (frame 5)
Colorbars frame Watermark frame
Colorbars Region Crop Watermark Region Crop
Colorbars crop Watermark crop
Notes
  • The watermark overlay region is dark (avg RGB 14,13,13) because position (1080,20) sits on the dark portion of SMPTE colorbars, blended at 0.9 opacity
  • The colorbars-only file (2.5MB) is larger than the watermark file (455KB) because the colorbars pipeline has no core::pacer — it dumps all 300 frames immediately, while the watermark pipeline paces at real-time speed

Devin session

streamkit-devin and others added 5 commits April 3, 2026 07:52
…ng, 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>
…ic 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>
…-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>
Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
…ch_else

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
staging-devin-ai-integration[bot]

This comment was marked as resolved.

streamkit-devin and others added 3 commits April 3, 2026 08:52
… 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>
* 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>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 15 additional findings in Devin Review.

Staging: Open in Devin
Debug

Playground

Comment thread crates/api/src/yaml.rs
Comment thread crates/api/src/yaml.rs
Comment on lines 1157 to 1159
pub fn lint_client_against_nodes(
client: &ClientSection,
_mode: EngineMode,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 lint_client_against_nodes has no production callers yet

The lint_client_against_nodes function (including the new Rules 21-22 for controls) is only called from unit tests — no production code in the server crate invokes it. The function and NodeInfo.name field are pub, suggesting integration is planned. Until then, the control-unknown-node and control-number-no-bounds lint rules provide no runtime protection. This isn't a bug — just worth noting that the lint coverage exists only at the unit-test level.

(Refers to lines 1157-1161)

Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

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>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 17 additional findings in Devin Review.

Staging: Open in Devin
Debug

Playground

// Resolved once at module level — getWebSocketService returns a singleton,
// so hoisting it avoids a new reference on every render and keeps
// tuneNodeConfig's useCallback deps minimal.
const wsService = getWebSocketService();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Module-level getWebSocketService() call executes at import time

const wsService = getWebSocketService() at line 16 runs when the module is first imported, which could be during app bootstrap before the WebSocket singleton is fully configured. The comment says it returns a singleton, and this pattern is used to keep useCallback deps minimal. If getWebSocketService uses lazy initialization internally (create on first send, not on call), this is safe. If it eagerly connects, it could fail or connect prematurely. Worth verifying the singleton's initialization strategy if this module is imported in test environments or SSR contexts.

Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Safe in this case — getWebSocketService() is a lazy singleton factory that only constructs a WebSocketService(url), which stores the URL string without connecting. The actual WebSocket connection is established later via an explicit connect() call (triggered by the app's connection lifecycle, not at import time). So module-level instantiation just creates a lightweight object with no side effects.

This matches the existing useSession pattern where getWebSocketService() is also called inline (though per-render there). The module-level hoist was intentional to keep useCallback deps stable.

@streamer45 streamer45 merged commit 11d4144 into main Apr 3, 2026
17 checks passed
@streamer45 streamer45 deleted the devin/1775161780-slint-native-plugin branch April 3, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants