diff --git a/.github/workflows/qt-release-candidate.yml b/.github/workflows/qt-release-candidate.yml
index 2d317732a..c157bc706 100644
--- a/.github/workflows/qt-release-candidate.yml
+++ b/.github/workflows/qt-release-candidate.yml
@@ -146,6 +146,14 @@ jobs:
mkdir -p build/release-artifacts
cpack --config build/qt-release/CPackConfig.cmake -C Release -G DEB -B build/release-artifacts
cmake --install build/qt-release --config Release --prefix "$PWD/build/AppDir/usr"
+ test -f build/AppDir/usr/bin/libopennow_streamer_ffi.so
+ test ! -e build/AppDir/usr/bin/opennow-streamer
+ deb=$(find build/release-artifacts -maxdepth 1 -type f -name '*.deb' -print -quit)
+ dpkg-deb --fsys-tarfile "$deb" | tar -tf - | grep -qx './usr/bin/libopennow_streamer_ffi.so'
+ if dpkg-deb --fsys-tarfile "$deb" | tar -tf - | grep -qx './usr/bin/opennow-streamer'; then
+ echo 'Standalone streamer must not be packaged' >&2
+ exit 1
+ fi
curl --fail --location --retry 3 \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-${LINUXDEPLOY_ARCH}.AppImage" \
--output "build/linuxdeploy-${LINUXDEPLOY_ARCH}.AppImage"
@@ -301,9 +309,9 @@ jobs:
$ErrorActionPreference = "Stop"
$pfx = Join-Path $env:RUNNER_TEMP "opennow-signing.pfx"
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_PFX_BASE64))
- $names = @("OpenNOW.exe", "opennow-core.exe", "opennow-streamer.exe", "opennow-acceptance-verify.exe")
+ $names = @("OpenNOW.exe", "opennow-core.exe", "opennow-acceptance-verify.exe", "opennow_streamer_ffi.dll")
$files = Get-ChildItem build/qt-release/Release -File | Where-Object { $names -contains $_.Name }
- if ($files.Count -ne 4) { throw "Expected exactly four release executables, found $($files.Count)" }
+ if ($files.Count -ne 4) { throw "Expected exactly four release binaries, found $($files.Count)" }
foreach ($file in $files) {
signtool sign /f $pfx /p $env:WINDOWS_PFX_PASSWORD /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 $file.FullName
signtool verify /pa /all $file.FullName
@@ -327,11 +335,15 @@ jobs:
$msiExpanded = Join-Path $env:RUNNER_TEMP "msi-expanded"
$process = Start-Process msiexec.exe -Wait -PassThru -ArgumentList "/a `"$($msi.FullName)`" /qn TARGETDIR=`"$msiExpanded`""
if ($process.ExitCode -ne 0) { throw "MSI administrative extraction failed" }
+ if (-not (Get-ChildItem $msiExpanded -Recurse -Filter opennow_streamer_ffi.dll)) { throw "MSI is missing the embedded streamer runtime" }
+ if (Get-ChildItem $msiExpanded -Recurse -Filter opennow-streamer.exe) { throw "MSI contains the obsolete standalone streamer" }
Get-ChildItem $msiExpanded -Recurse -Filter *.exe | ForEach-Object { signtool verify /pa /all $_.FullName }
$zip = Get-ChildItem build/release-artifacts -Filter *.zip
if ($zip.Count -ne 1) { throw "Expected one portable ZIP" }
$expanded = Join-Path $env:RUNNER_TEMP "portable"
Expand-Archive $zip.FullName $expanded
+ if (-not (Get-ChildItem $expanded -Recurse -Filter opennow_streamer_ffi.dll)) { throw "Portable ZIP is missing the embedded streamer runtime" }
+ if (Get-ChildItem $expanded -Recurse -Filter opennow-streamer.exe) { throw "Portable ZIP contains the obsolete standalone streamer" }
Get-ChildItem $expanded -Recurse -Filter *.exe | ForEach-Object { signtool verify /pa /all $_.FullName }
- name: Record artifact checksums
@@ -445,6 +457,8 @@ jobs:
mkdir -p build/release-artifacts
app="$RUNNER_TEMP/stage/OpenNOW.app"
test -d "$app"
+ test -f "$app/Contents/MacOS/libopennow_streamer_ffi.dylib"
+ test ! -e "$app/Contents/MacOS/opennow-streamer"
while IFS= read -r -d '' item; do
codesign --force --options runtime --timestamp --keychain "$OPENNOW_SIGNING_KEYCHAIN" \
--sign "$SIGN_IDENTITY" "$item"
diff --git a/docs/core-protocol.md b/docs/core-protocol.md
index 72ddff55c..094db4399 100644
--- a/docs/core-protocol.md
+++ b/docs/core-protocol.md
@@ -67,6 +67,7 @@ most 512 items; overflow drops the oldest event and emits a diagnostic counter.
- `session.active.get`
- `session.remote.list`, `session.claim`, `session.ad.report`
- `streamer.detect`
+- `streamer.prepare`
- `streamer.start`
- `streamer.status.get`
- `streamer.stop`
@@ -94,13 +95,12 @@ cached in the core process and bounded per response.
CloudMatch session methods preserve one client/device identity through create,
poll and stop, retain pending queue responses before signaling is available,
and return the complete ordered connection, ICE and negotiated-feature payload
-needed by the native streamer. Streamer methods supervise the out-of-process
-protocol-v4 runtime, secure NVIDIA signaling, SDP/ICE relay, health state and
-bounded shutdown.
-The shell supplies bounded global surface geometry on startup and debounced
-move/resize updates at runtime. Both WebRTC and classic NVST receive a visible
-surface before media presentation; the external native window remains behind
-the transparent Qt guide layer while it owns gameplay input.
+needed by the native streamer. `streamer.prepare` returns the normalized session
+context used by the NVST runtime linked into the Qt shell. The in-process runtime
+owns secure NVIDIA signaling, ICE/DTLS/SCTP, RTSPS, Mjolnir, RTCP and native
+gameplay input, while Qt owns the graphics device, scene graph, video item and
+all top-level windows. Legacy streamer lifecycle methods remain protocol
+compatibility routes and are not used by the Qt shell.
`acceptance.export` is available only through the Qt shell's Diagnostics screen. It rejects
headless window systems and writes an atomic, redacted `opennow.live-acceptance` JSON file. The
diff --git a/docs/qt-acceptance.md b/docs/qt-acceptance.md
index 25046c237..9564296ce 100644
--- a/docs/qt-acceptance.md
+++ b/docs/qt-acceptance.md
@@ -20,15 +20,12 @@ Use an authorized test account with no production secrets in reports. Sign in in
not put NVIDIA credentials, refresh tokens, signing keys, or notarization passwords in command
arguments, logs, issue trackers, or acceptance artifacts.
-The presenter remains out of process. Windows reparents its HWND as a child of the Qt top-level
-window. X11, Wayland and macOS use a paired native top-level window aligned to the stream region.
-The surface contract carries a window handle, host-local and screen geometry, visibility, and scale,
-but it does not establish cross-process Qt texture embedding. Foreign children and paired windows
-cannot be covered reliably by ordinary Qt Quick items, so the shell hides the presenter before it
-shows a QML menu, stats panel, reconnect screen, or error screen. The result is Qt-owned UI with a
-temporarily suspended video surface, not a composited overlay over live video. No matrix row may
-claim single-window composition or zero-copy into the Qt scene graph without a separate implementation
-and measurement evidence.
+The streamer is loaded by the Qt executable as an in-process Rust library. Platform decoders publish
+native GPU frames through a bounded FFI mailbox; `StreamVideoItem` records conversion and
+synchronization into the active QRhi command buffer and samples the imported texture in the Qt scene
+graph. There is no child streamer process, child HWND or paired native video window. Acceptance must
+still prove each platform's native texture import and synchronization on real hardware; the design
+alone is not performance or zero-copy evidence.
## Performance evidence
@@ -56,7 +53,7 @@ requires `pass: true` for both reports. The hardware flag rejects offscreen/mini
software/null renderers, missing screens, and workloads that do not receive the requested physical
dimensions. It also rejects the test-only refresh-rate override, so release evidence always uses
the display-reported rate. This measures the Qt shell workload; it does not prove stream-window
-embedding, native decoder throughput or a zero-copy handoff into Qt.
+native decoder throughput or GPU texture-import behavior.
## Authorized stream evidence
@@ -66,32 +63,30 @@ ten minutes. Exercise the following without restarting the app:
1. Complete device login, account switching, subscription and region refresh.
2. Create a session, pass queue/ads if present, reach native NVST first-frame playback, and confirm
the live evidence reports `stream.transport: "nvst"`.
-3. Open and close every guide and stats page while video is live. Confirm the presenter hides before
- QML appears, returns after QML closes, never leaves a stale native handle, and transfers controller
- input atomically. Record Windows as child-HWND behavior and the other platforms as paired-window
- behavior rather than claiming a composited live-video overlay.
+3. Open and close every guide and stats page while video is live. Confirm QML composes above the
+ scene-graph video item without suspending playback, stale frame tokens or input leakage.
4. Exercise keyboard, relative mouse, and every connected controller. Validate neutral controller
state after overlay entry, reconnect, pause, and resume.
5. Test window resize, fullscreen, display migration, the display's highest supported refresh
rate, and VRR/HDR only where the machine advertises them.
6. Load a profile that previously selected WebRTC or another legacy transport and confirm settings,
session creation, streamer status and exported evidence all resolve it to NVST. If a persisted
- microphone mode is armed, confirm the runtime reports upstream audio as unavailable without
- changing transport; microphone audio is not a release gate for the NVST-only client.
+ microphone mode is armed, confirm settings migration disables it without changing transport;
+ microphone capture is not part of the native runtime.
7. Capture a screenshot, start and stop a source-stream Matroska recording, play the resulting
media, verify the generated thumbnail, and reveal both files through the Media screen.
-8. Rebind and exercise all eight stream shortcuts. Confirm stats and fullscreen reach Qt exactly once,
- pointer lock remains native, microphone reports unavailable, screenshot, recording and stop reach
- the shell exactly once, and anti-AFK produces an F13 pulse after four minutes without leaking the
- key into the game.
+8. Rebind and exercise all seven active stream shortcuts. Confirm stats and fullscreen reach Qt
+ exactly once, pointer lock remains native, screenshot, recording and stop reach the shell exactly
+ once, and anti-AFK produces an F13 pulse after four minutes without leaking the key into the game.
9. Enable the anti-AFK indicator/reminder and session clock, then confirm the post-session report
reflects NVST transport, elapsed time, backend, first-frame latency, recovery/error counters and
diagnostics navigation.
10. Exercise favorites, entitlement-filtered aspect ratio/resolution/FPS choices, keyboard layout,
game language, console-friendly launch and in-game-settings persistence on a title that advertises
the corresponding NVIDIA feature.
-11. Force one recoverable network interruption and one streamer-process failure. Confirm bounded
- reconnect/restart behavior, no stuck input, and a usable error if recovery is exhausted.
+11. Force one recoverable network interruption and one graphics-device or native-runtime failure.
+ Confirm bounded reconnect/reinitialization behavior, no stuck input, and a usable error if
+ recovery is exhausted.
12. Export both the redacted diagnostic report and **live evidence** from the Diagnostics screen
after the run. The live export is direct machine-readable JSON and must report
`observedPass: true`; it includes hashed screenshot/recording/thumbnail metadata and bounded
diff --git a/docs/qt-migration.md b/docs/qt-migration.md
index b37d2c496..5d03a08f0 100644
--- a/docs/qt-migration.md
+++ b/docs/qt-migration.md
@@ -8,19 +8,20 @@ until every removal gate at the end of this document passes.
```text
Qt Quick/QML shell
- -> thin Qt/C++ bridge
- -> versioned OpenNOW core RPC
- -> Rust application services
- -> Rust native streamer process
+ -> thin Qt/C++ bridge -> linked Rust NVST runtime
+ -> versioned OpenNOW core RPC -> Rust application services
```
The QML layer owns presentation, motion, spatial focus, responsive layout and
accessible interaction. It must not own GFN protocol details, credentials,
filesystem access, native process management or stream transport.
-The Rust application core owns authentication, GFN orchestration, settings,
-storage, updates, diagnostics and platform services. The native streamer remains
-out of process so decoder or driver failures cannot take down the shell.
+The Rust application core remains out of process and owns authentication, GFN
+orchestration, settings, storage, updates, diagnostics and platform services.
+The NVST media runtime is a shared library loaded into the Qt process. Qt owns
+the D3D11, Metal or Vulkan graphics device and scene-graph presentation; Rust
+decodes into native GPU frames and records conversion work on Qt's command
+stream without creating another application window or presenter process.
## Phase checklist
@@ -118,18 +119,15 @@ out of process so decoder or driver failures cannot take down the shell.
### 6. Native streaming
-- [x] Replace Electron-specific surface ownership with a shell-neutral external-window contract. The
- core preserves `windowHandle`, `rect`, `screenRect`, visibility and scale updates and always starts
- the out-of-process presenter with `OPENNOW_NATIVE_EXTERNAL_RENDERER=1`.
-- [x] Implement native frame presentation and shell/streamer window ordering for Windows, macOS,
- X11 and Wayland, with controller ownership transferred atomically while QML guide overlays are
- active. Windows reparents the presenter HWND into the Qt top-level window; X11, Wayland and macOS
- currently use paired native top-level windows. None is a texture embedded in the Qt scene graph.
- Because ordinary QML cannot reliably cover these native surfaces, the typed controller hides
- presentation for QML menus, stats, reconnect and error states, then restores it from fresh host
- geometry. Cross-OS live-stream ordering proof remains an acceptance gate, and this implementation
- does not claim a live-video QML overlay or cross-platform zero-copy.
-- [x] Preserve out-of-process lifecycle, protocol-v5 health checks and restart isolation.
+- [x] Replace Electron-specific surface ownership with an in-process GPU frame contract. The Qt
+ render thread lends QRhi native objects to the in-process Rust library, acquires opaque frame tokens,
+ records conversion/synchronization into Qt's command buffer and samples the imported texture in
+ `StreamVideoItem`.
+- [x] Present native decoder frames inside the Qt scene graph on Windows, macOS, X11 and Wayland.
+ QML menus, stats, reconnect and error states compose above live video without child HWNDs, paired
+ windows or presenter hide/show ordering. Cross-OS live GPU interop remains an acceptance gate.
+- [x] Replace the streamer subprocess with a bounded C FFI linked into `opennow-qt`; preserve the
+ protocol-v5 engine contract internally without packaging a helper executable.
- [x] Preserve hardware decode selection and safe fallback behavior for every negotiable codec. NVST
H.264/H.265/AV1 profiles, strict automatic/hardware/software selection, prelaunch capability
probing, Windows class-separated Media Foundation hardware and system-software probing/fallback,
@@ -143,13 +141,12 @@ out of process so decoder or driver failures cannot take down the shell.
- [x] Integrate stream statistics, next-session bitrate, recording and Cloud G-Sync quick controls.
The configurable stats shortcut is forwarded as `shortcut-action: toggle-stats`; Qt owns the
overlay and renders core telemetry instead of asking the native presenter to draw it.
-- [x] Fail closed when a persisted microphone mode is selected: upstream microphone audio is not
- implemented by the NVST runtime, and diagnostics report it as unavailable rather than selecting a
- WebRTC media session.
-- [x] Apply all eight configurable native shortcuts, including pointer lock, recording, screenshot,
- microphone, stop and real four-minute anti-AFK F13 pulses. Stats and fullscreen are forwarded to
- Qt instead of mutating native presentation; pointer lock remains native. The microphone shortcut
- retains settings compatibility but reports NVST upstream audio as unavailable.
+- [x] Migrate persisted microphone modes to disabled. Microphone capture and upstream audio are not
+ part of the NVST runtime, and legacy values cannot select another transport.
+- [x] Apply the seven active native shortcuts, including pointer lock, recording, screenshot, stop
+ and real four-minute anti-AFK F13 pulses. Stats and fullscreen are forwarded to Qt; pointer lock
+ remains native. The removed microphone shortcut key remains accepted only as persisted settings
+ data so upgrades do not fail to load.
- [ ] Validate HDR, high-refresh, VRR, resize, fullscreen and display migration.
- [ ] Validate screenshots and source-stream recording with an authorized live session on each supported OS.
@@ -187,7 +184,7 @@ out of process so decoder or driver failures cannot take down the shell.
- [ ] Performance budgets pass on representative low-end hardware.
- [x] Ship a fail-closed acceptance verifier for live evidence, 1080p/4K reports, manual hardware
attestations, required platform package types, package hashes, signing and update verification.
-- [x] Offline, partial-stream, core-restart and streamer-crash tests pass with bounded queues, typed failures, graceful EOF shutdown and supervised child recovery.
+- [x] Offline, partial-stream, core-restart and native-runtime failure tests pass with bounded queues, typed failures, graceful shutdown and bounded recovery.
- [ ] Authorized live sessions pass on every supported OS/window-system pair.
- [x] Upgrade, downgrade and rollback fixtures preserve user data: unknown Electron fields survive Qt saves, imported credentials leave the Electron source untouched, and failed AppImage replacement restores the previous executable.
- [ ] Qt release has completed a staged rollout with diagnostics monitored.
@@ -220,26 +217,12 @@ codec-specific tracking, main-thread configuration commands, reconfiguration and
handling. Full wrapper linking and execution require the Apple SDK and therefore still run only
on the macOS CI/hardware gate.
-The same checkpoint builds the release shell with bundled FFmpeg and produces a
-20,623,628-byte amd64 DEB (49,695 KiB installed). An extracted-package smoke
-launch passed while explicitly using the packaged `opennow-core` and
-`opennow-streamer`, and the archive contains the desktop entry, AppStream
-metadata, icon, license and generated third-party notices. The exact local artifact has SHA-256
-`e1d076182530a3e128f18708371bdbe6c936cdc9fd6dbcaad826121e13cfa1bf`.
-
-The native NVST launch context now carries exactly the CloudMatch codec configured
-for the active decoder. A protocol-v5 child-process probe applies the selected
-decoder policy before CloudMatch allocates a session, including automatic H.264;
-explicit HEVC/AV1 sessions remain available only where the selected native backend
-honestly reports them. CPack packages the Qt shell, Rust core, native streamer,
-desktop metadata, Qt deployment output and exact dependency notices; the CI path
-builds the distributable Linux streamer with bundled FFmpeg and smoke-tests the
-checksum-pinned x64 AppImage.
-
-The extracted DEB starts the Qt shell against its packaged core and streamer.
-Its live protocol-v5 probe reports the bundled H.264/H.265/AV1 codecs, applies an
-explicit unavailable V4L2 policy as zero available codecs, and rejects
-`session.create` with `streamer_codec_unavailable` before any provider request.
+The Qt target links `opennow-streamer-ffi` as an in-process Rust shared library and packages no
+`opennow-streamer` executable. `opennow-core` remains a separate account/session service, but it
+only prepares the normalized NVST launch context; Qt sends media lifecycle and input commands to
+the linked runtime. The native launch context carries exactly the CloudMatch codec configured for
+the active decoder, and explicit HEVC/AV1 sessions remain available only where the linked backend
+reports them.
These are development-host checkpoints, not live-account or representative-GPU
acceptance results. Production update keys/assets, signed/notarized multi-arch
@@ -256,19 +239,23 @@ manifest with both hardware performance reports, the explicit manual-attestation
required platform packages; it fails closed on any false/mismatched gate and emits a path-free
verification result.
-The NVST-only core no longer contains its former browser WebSocket/SDP/ICE media fallback or the
-associated Tungstenite dependency. Persisted `transportMode` remains part of the settings contract
+The NVST-only core no longer contains its former browser offer/answer, trickle-ICE, RTP media,
+data-channel input or microphone fallback. Persisted `transportMode` remains part of the settings contract
for rollback compatibility, but every legacy value normalizes to `nvst` before session creation or
-streamer launch. NVIDIA still requires some protocol labels whose names contain `WEBRTC`: device
+runtime launch. NVIDIA still requires some protocol labels whose names contain `WEBRTC`: device
authorization and browser-style region discovery retain the `nv-client-streamer: WEBRTC` identity.
-The native streamer also retains DTLS/SCTP-named bundle, input and control structures because NVST
-audio, input and RTCP use that encrypted bundle. Those names are wire compatibility, not a second
-media transport.
-
-No cross-process zero-copy path into the Qt scene graph is implemented or claimed. Hardware decode
-and native presentation can still avoid software decode, but acceptance must treat that separately
-from Qt texture sharing. macOS and Wayland acceptance therefore verifies the supported two-window
-ordering and input-ownership model rather than requiring single-window embedding.
+The runtime also retains Tungstenite/rustls for NVIDIA's RTSPS-over-WebSocket negotiation and
+str0m-backed ICE/DTLS/SCTP bundle, input and control structures because NVST audio, input and RTCP
+use that encrypted bundle. Those dependencies are NVST wire compatibility, not a second media
+transport.
+
+Qt owns the graphics API and scene graph: D3D11 on Windows, Metal on macOS and Vulkan on Linux.
+Decoded frames stay in-process and are converted into frame-slot RGBA textures on Qt's device and
+command stream before `StreamVideoItem` samples them. QML overlays therefore remain in the same
+scene graph, and embedded mode creates no presenter window, child window, swapchain or platform
+surface. Linux retains a synchronized CPU-plane-to-GPU upload fallback for unsupported direct
+imports; this is not described as zero-copy. Live device, high-refresh, display-transition and
+device-loss acceptance remains required separately on each target operating system.
The exact live-account, hardware, signing and rollout procedure is maintained in
[`docs/qt-acceptance.md`](qt-acceptance.md). It defines which artifacts constitute proof, so an
diff --git a/docs/streamer-comparison/audio.md b/docs/streamer-comparison/audio.md
index 2bbe12b3a..b9703de7d 100644
--- a/docs/streamer-comparison/audio.md
+++ b/docs/streamer-comparison/audio.md
@@ -4,7 +4,7 @@
Both clients play stereo Opus at 48 kHz through WASAPI. Official GFN keeps a timestamped jitter buffer (`NVST:TimestampAudioBuffer`) and asks the server for RFC 2198 RED at redundancy level 2. OpenNOW advertises `aqos.enableRedundancy:0`, decodes with `fec=false`, and has no audio jitter buffer. Loss is a drop. Underrun is a WASAPI stop and reset, not silence.
-Microphone is the other split. Official GFN has an Opus encoder wrapper and mic RED level 3. OpenNOW advertises NVST mic ports in RTSP and then refuses capture.
+Microphone is the other split. Official GFN has an Opus encoder wrapper and mic RED level 3. OpenNOW has no microphone capture or upstream transport path.
Game audio works. It will not conceal gaps the way official GFN does, and the mic button will not reach the session.
@@ -85,7 +85,7 @@ There is no A/V sync. Video uses `PresentationClock`. Audio is decode-and-push.
## Microphone
-OpenNOW RTSP still writes `rtcMicOnNativeBundle:1` and `clientPorts.mic:0`. `native/opennow-core/src/streamer.rs` then refuses microphone upstream on this path. There is no NVST send.
+OpenNOW's NVST ANNOUNCE compatibility profile still writes `rtcMicOnNativeBundle:1` and `clientPorts.mic:0`, but the runtime has no microphone capture, encoder, queue or send path. Persisted microphone settings migrate to disabled.
Official logs `NVST:OpusAudioEncoderWrapper` payload 20 ms, 2 channels, `mVoiceBitrate 16000`, in-band FEC disabled, mic RED 3.
@@ -93,7 +93,6 @@ Official logs `NVST:OpusAudioEncoderWrapper` payload 20 ms, 2 channels, `mVoiceB
- WASAPI. `native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/audio.rs`
- Opus decode. `native/opennow-streamer/crates/opennow-streamer-platform/src/media.rs`
-- Mic. `native/opennow-streamer/crates/opennow-streamer-platform/src/microphone.rs`
- NVST audio and RED strip. `native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs`
- ANNOUNCE audio attrs. `native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs`
- CloudMatch stereo. `native/opennow-core/src/cloudmatch.rs`
diff --git a/docs/streamer-comparison/compare.html b/docs/streamer-comparison/compare.html
index 67340d7a0..ed64b8f26 100644
--- a/docs/streamer-comparison/compare.html
+++ b/docs/streamer-comparison/compare.html
@@ -178,7 +178,7 @@
Verdict
OpenNOW native · Qt
-
Owned RTSPS. Mjolnir video. Bundle audio and input. Media Foundation plus tearing present. Raw Input thread for relative mouse. Client-side type-7 tuning. No captured host mouse-settings frame. Native CloudMatch forces 8-bit 4:2:0. Audio has no jitter buffer and no RED. Mic is advertised, then refused.
+
Owned RTSPS. Mjolnir video. Bundle audio and input. Media Foundation plus tearing present. Raw Input thread for relative mouse. Client-side type-7 tuning. No captured host mouse-settings frame. Native CloudMatch forces 8-bit 4:2:0. Audio has no jitter buffer and no RED. There is no microphone capture or send path.
Official concealment is a timestamped buffer plus RED. OpenNOW concealment is “drop the packet.” WASAPI underrun stops the client and raises preroll. That is a hole, not a fade.
-
ANNOUNCE still writes rtcMicOnNativeBundle:1. Core then refuses NVST mic. There is no send path. Official mic this session was 20 ms, 2 ch, 16 kbps, RED 3.
+
ANNOUNCE still writes rtcMicOnNativeBundle:1 for compatibility, but the runtime has no microphone capture, encoder, queue or send path. Official mic this session was 20 ms, 2 ch, 16 kbps, RED 3.
diff --git a/docs/streamer-comparison/mouse-input.md b/docs/streamer-comparison/mouse-input.md
index 69281460e..db2ab3055 100644
--- a/docs/streamer-comparison/mouse-input.md
+++ b/docs/streamer-comparison/mouse-input.md
@@ -105,22 +105,22 @@ NVST wrap is `COMMAND_REMOTE_INPUT` `0x0206`. Absolute encode injects flags `0x0
1. **Host curve.** Official disables session accel and pins speed 10 on every focus change. The type 10 frame itself has not been captured. This is still the first thing to match if you want official look.
2. **Client curve.** OpenNOW can scale type 7 before send. Official logs imply raw counts.
3. **i16 split.** A large HID burst becomes several type-7 packets. Official may keep a wider delta.
-4. **Foreground filter.** OpenNOW requires the SDL HWND to be foreground. Overlay is special-cased. A real Qt steal drops all mouse. Official async thread is also focus-scoped, but it restarts on window state 19 and resends alt settings.
+4. **Foreground filter.** The embedded Windows capture binds Raw Input ownership to the Qt window and pauses capture while an overlay owns input. Official's async thread is also focus-scoped, but it restarts on window state 19 and resends alternate settings.
5. **Binary cursor mode.** Official has visible-unlocked, visible-warped, and hidden-locked. OpenNOW has hidden versus visible. Custom bitmap id 0 stays visible.
-6. **SDL still grabs** while Raw Input owns motion. Official may keep lock inside `RawInputController`.
+6. **Qt owns pointer state** while the embedded Raw Input controller owns relative motion. Official may keep lock inside `RawInputController`.
7. **Drain stalls.** Encode and SCTP share the NVST event thread. A hitch batches then bursts. The 300 ms unordered lifetime exists because ordered PR-SCTP already felt like delayed catch-up.
## Where things live
- Raw Input. `native/opennow-streamer/crates/opennow-streamer-platform/src/windows_raw_input.rs`
-- SDL grab and cursor. `native/opennow-streamer/crates/opennow-streamer-platform/src/output.rs`
+- Embedded capture ownership. `native/opennow-streamer/crates/opennow-streamer-platform/src/embedded_input.rs`
- Tuning and packet. `native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs` `tune_relative_mouse`, `captured_input_packet`
- NVST encode and channels. `native/opennow-streamer/crates/opennow-streamer-transport/src/nvst_input.rs`
-- Launch env. `native/opennow-core/src/streamer.rs`
+- Qt input bridge. `opennow-qt/src/NativeStreamRuntime.cpp`
## Gotchas
-`owned_window_rejects_hit_testing` is the D3D child HWND used when video is embedded. Qt's real path presents on the SDL HWND, which is a popup that takes focus. That child is not what eats mouse.
+The Qt path has no SDL presenter HWND. The shell passes its own native window handle to the embedded capture controller and submits Qt/controller events directly through the FFI.
Absolute `MOUSE_MOVE_ABSOLUTE` reports never become type 5. Tablets and some virtual mice produce no relative motion.
diff --git a/locales/en.json b/locales/en.json
index ffa0c9499..0c0519282 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -2600,6 +2600,19 @@
"s_qt_runtime_229": "Fullscreen",
"s_qt_runtime_230": "Shows live stream statistics",
"s_qt_runtime_231": "Starts the next session with compact stats",
- "s_qt_runtime_232": "Stream controls are available once the session is live."
+ "s_qt_runtime_232": "Stream controls are available once the session is live.",
+ "s_qt_runtime_233": "The embedded media runtime could not start",
+ "s_qt_runtime_234": "The embedded media runtime rejected a command",
+ "s_qt_runtime_235": "Preparing the embedded native media runtime…",
+ "s_qt_runtime_236": "The core could not prepare the native stream context",
+ "s_qt_runtime_237": "This stream control is unavailable",
+ "s_qt_runtime_238": "The embedded media runtime returned an invalid handshake",
+ "s_qt_runtime_239": "Native-owned NVST media transport is active",
+ "s_qt_runtime_240": "Stream stopped",
+ "s_qt_runtime_241": "Native media runtime failed",
+ "s_qt_runtime_242": "Recording failed",
+ "s_qt_runtime_243": "The embedded media runtime stopped",
+ "s_qt_runtime_244": "The core returned an invalid embedded stream context",
+ "s_qt_runtime_245": "The embedded media runtime could not start the stream"
}
}
diff --git a/native/opennow-core/src/main.rs b/native/opennow-core/src/main.rs
index 142f69a85..cb7564b14 100644
--- a/native/opennow-core/src/main.rs
+++ b/native/opennow-core/src/main.rs
@@ -523,6 +523,13 @@ fn dispatch(method: &str, params: &Value, core: &AppCore) -> DispatchResult {
.map(|value| (value.clone(), Some(("streamer.changed", value))))
.map_err(streamer_error)
}
+ "streamer.prepare" => {
+ let settings = core.settings.lock().expect("settings poisoned").all();
+ core.streamer
+ .prepare_embedded(params, &settings)
+ .map(|value| (value, None))
+ .map_err(streamer_error)
+ }
"streamer.status.get" => Ok((core.streamer.status(), None)),
"streamer.stop" => core
.streamer
diff --git a/native/opennow-core/src/streamer.rs b/native/opennow-core/src/streamer.rs
index 52c84ff0b..1b73d1170 100644
--- a/native/opennow-core/src/streamer.rs
+++ b/native/opennow-core/src/streamer.rs
@@ -298,6 +298,29 @@ impl StreamerService {
ensure_codec_available(&detection["capabilities"], codec)
}
+ pub fn prepare_embedded(
+ &self,
+ params: &Value,
+ settings: &Value,
+ ) -> Result {
+ let session = params["session"]
+ .as_object()
+ .map(|_| params["session"].clone())
+ .ok_or_else(|| invalid("streamer.prepare requires a ready session"))?;
+ let status = session["status"].as_i64().unwrap_or_default();
+ if !matches!(status, 2 | 3) {
+ return Err(invalid(
+ "CloudMatch session is not ready for NVST media attachment",
+ ));
+ }
+ let mut context = streamer_context(session, settings);
+ context["surface"] = Value::Null;
+ Ok(json!({
+ "protocolVersion": STREAMER_PROTOCOL_VERSION,
+ "context": context
+ }))
+ }
+
pub fn start(&self, params: &Value, settings: &Value) -> Result {
self.reap_finished();
let mut worker = self.worker.lock().expect("streamer worker poisoned");
@@ -1632,6 +1655,30 @@ mod tests {
assert_eq!(context_resolution(&context), (1920, 1080));
}
+ #[test]
+ fn embedded_prepare_returns_nvst_context_without_a_native_surface() {
+ let service = StreamerService::new();
+ let prepared = service
+ .prepare_embedded(
+ &json!({
+ "session": {
+ "sessionId": "session-one",
+ "status": 2,
+ "signalingUrl": "wss://server.nvidiagrid.net/nvst/"
+ }
+ }),
+ &json!({"codec":"auto","transportMode":"webrtc","resolution":"1920x1080"}),
+ )
+ .expect("embedded context");
+
+ assert_eq!(prepared["protocolVersion"], STREAMER_PROTOCOL_VERSION);
+ assert_eq!(prepared["context"]["session"]["sessionId"], "session-one");
+ assert_eq!(prepared["context"]["settings"]["codec"], "H264");
+ assert_eq!(prepared["context"]["settings"]["transportMode"], "nvst");
+ assert_eq!(prepared["context"]["surface"], Value::Null);
+ assert!(service.worker.lock().expect("streamer worker").is_none());
+ }
+
#[test]
fn microphone_child_state_is_exposed_without_losing_the_message() {
let state = Arc::new(Mutex::new(Snapshot::default()));
diff --git a/native/opennow-streamer/Cargo.lock b/native/opennow-streamer/Cargo.lock
index bc251e068..4f9319df0 100644
--- a/native/opennow-streamer/Cargo.lock
+++ b/native/opennow-streamer/Cargo.lock
@@ -1634,15 +1634,23 @@ dependencies = [
"opennow-streamer-transport",
"rustls",
"serde_json",
- "str0m",
"tungstenite",
]
+[[package]]
+name = "opennow-streamer-ffi"
+version = "0.2.0"
+dependencies = [
+ "opennow-streamer-core",
+ "opennow-streamer-platform",
+ "opennow-streamer-protocol",
+ "serde_json",
+]
+
[[package]]
name = "opennow-streamer-platform"
version = "0.2.0"
dependencies = [
- "audiopus_sys",
"base64",
"image",
"libc",
diff --git a/native/opennow-streamer/Cargo.toml b/native/opennow-streamer/Cargo.toml
index dadfed52b..ddc26f4af 100644
--- a/native/opennow-streamer/Cargo.toml
+++ b/native/opennow-streamer/Cargo.toml
@@ -2,6 +2,7 @@
members = [
"crates/opennow-streamer",
"crates/opennow-streamer-core",
+ "crates/opennow-streamer-ffi",
"crates/opennow-streamer-platform",
"crates/opennow-streamer-platform-linux",
"crates/opennow-streamer-platform-macos",
diff --git a/native/opennow-streamer/README.md b/native/opennow-streamer/README.md
index 7da24da3c..f57c9bfbd 100644
--- a/native/opennow-streamer/README.md
+++ b/native/opennow-streamer/README.md
@@ -1,57 +1,43 @@
-# OpenNOW Native Streamer v2
+# OpenNOW native streamer
-This workspace is the clean replacement for the former GStreamer-based native streamer.
+This workspace implements the native GeForce NOW NVST runtime. It owns RTSPS negotiation, the dedicated Mjolnir SRTP video socket, the NVST ICE/DTLS/SCTP bundle used for audio, RTCP and input, bounded media queues, platform decode/audio output, source-stream Matroska recording and GPU frame publication. It does not implement the browser WebRTC offer/answer or trickle-ICE protocol and it has no microphone upstream path.
-The workspace owns the local process protocol, lifecycle state machine, GeForce NOW RTSPS/NVST negotiation, standards-based WebRTC transport, media sockets, media output path, microphone upstream and source-stream Matroska recording. The UI supplies a complete CloudMatch session context once; the streamer reserves its own bundle/Mjolnir sockets and performs OPTIONS, DESCRIBE, SETUP, ANNOUNCE, PLAY, keepalive, and TEARDOWN itself. OpenH264, Opus, SDL, and the Linux FFmpeg codec stack are compiled into the executable; GStreamer and external codec processes are not used.
-
-The executable retains the versioned JSON-lines process contract used by OpenNOW while the app shell is migrated away from Electron. It does not load or redistribute NVIDIA client libraries.
+The Qt application loads `opennow-streamer-ffi` as an in-process shared library. Qt supplies one complete CloudMatch session context; the embedded engine reserves its bundle and Mjolnir sockets and performs OPTIONS, DESCRIBE, SETUP, ANNOUNCE, PLAY, keepalive and TEARDOWN. The runtime does not load or redistribute NVIDIA client libraries.
## Crates
-- `opennow-streamer-protocol`: versioned local IPC DTOs.
-- `opennow-streamer-core`: session lifecycle and command routing.
-- `opennow-streamer-transport`: ICE, DTLS-SRTP, RTP/RTCP, and SCTP data channels.
-- `opennow-streamer-platform`: bounded media queues, OpenH264/Opus decode, SDL audio/video output, zero-reencode H.264/H.265/AV1 plus Opus Matroska recording, and shell-neutral native surface ownership.
-- `opennow-streamer-platform-windows`: strictly separated Media Foundation H.264/HEVC/AV1 hardware and system-software decode, D3D11 NV12/P010 presentation, and WASAPI PCM output for Windows x64 and ARM64.
-- `opennow-streamer-platform-macos`: VideoToolbox H.264/HEVC hardware decode, zero-copy IOSurface/Metal presentation, and CoreAudio output.
-- `opennow-streamer`: process entry point.
-
-## Checks
-
-```sh
-cargo test --manifest-path native/opennow-streamer/Cargo.toml
-cargo build --manifest-path native/opennow-streamer/Cargo.toml --release
-```
+- `opennow-streamer-protocol`: versioned local command and session DTOs.
+- `opennow-streamer-core`: NVST lifecycle, command routing, media feedback and recording.
+- `opennow-streamer-transport`: Mjolnir SRTP plus the NVST-required ICE/DTLS/SCTP, RTCP and input implementation.
+- `opennow-streamer-platform`: bounded media queues, decode/audio output, recording and GPU-frame publication.
+- `opennow-streamer-platform-{windows,macos,linux}`: platform decoders and native GPU texture producers.
+- `opennow-streamer-ffi`: bounded C ABI used in process by Qt.
+- `opennow-streamer`: a development JSON-lines host for the same engine; Qt packages do not include it.
-The legacy Electron build wrapper still checks protocol-version parity during migration. The Qt build compiles and bundles the same executable directly:
+## Qt GPU integration
-```sh
-npm --prefix opennow-stable run native:build
-```
+The Qt path does not create an SDL video window or a child streamer process. `NativeStreamRuntime` owns the embedded Rust handle, and `StreamVideoItem` drives the GPU-only FFI from Qt's render thread:
-## Platform integration
+1. Qt lends the current QRhi native graphics objects to the runtime.
+2. Platform decode publishes a native GPU frame into a one-frame, drop-stale mailbox.
+3. Qt acquires the latest opaque frame token and asks Rust to record conversion and synchronization into the same QRhi command buffer that will render it.
+4. Qt imports the returned RGBA8 native texture and samples it in the scene graph, so QML overlays compose above video normally.
+5. Qt releases the token after GPU use and shuts down the scene-graph binding on the render thread before QRhi teardown.
-The SDL presentation window is created hidden on the process main thread. Windows supports Media Foundation over either native D3D11 or a D3D12-backed D3D11-on-12 device and uses WASAPI for audio. Hardware mode enumerates only hardware MFTs. Software mode keeps bundled OpenH264/SDL for H.264 and can use registered D3D11-aware software MFTs for HEVC/AV1. Automatic mode performs the same class-preserving fallback after startup or unrecoverable device loss. `OPENNOW_NATIVE_VIDEO_BACKEND=software` forces that software path, while `d3d12` and `d3d11` select the corresponding hardware graphics path. Auto prefers D3D12 when its complete decode, presentation, and audio probe succeeds. Renderer surface rectangles are already physical pixels and are never scaled again by `deviceScaleFactor`. On Windows the Qt launch contract reparents the presenter HWND as an input-capable child of the Qt top-level window. Linux/X11 and Linux/Wayland use a separate compositor-managed SDL window aligned to the shell's stream region; that window owns native keyboard, mouse, relative-pointer, and cursor handling. macOS also uses a standalone, resizable SDL/AppKit window because AppKit cannot embed a view across processes. VideoToolbox decodes hardware-supported H.264, HEVC and AV1 into IOSurface-backed pixel buffers and Metal renders directly into that window's SDL-managed `CAMetalLayer`; the window owns keyboard, mouse, relative-pointer, and cursor handling. Fullscreen and other shell UI actions are emitted to Core/Qt; native presentation follows the surface geometry supplied by the shell and does not mutate fullscreen state. AV1 format configuration is derived from the first sequence-header keyframe and reconfigured atomically when that header changes. The macOS streamer is a regular LaunchServices application with its own menu bar, Dock identity, process coalition, and AppKit activation lifecycle. The executable runs `MainThreadHost` on its real main thread; FIFO IPC, NVST transport (including its WebRTC-compatible ICE/DTLS/SCTP control bundle), recording and codec work run on named workers. This is required by AppKit and is checked with `pthread_main_np()` on macOS.
+The FFI exposes no CPU image, encoded-frame callback, swap chain, window or Qt object. See `crates/opennow-streamer-ffi/README.md` for ownership and threading details.
-The app and native child select the same Linux window system. `OPENNOW_NATIVE_WINDOW_SYSTEM=x11|wayland` records that launch contract and `SDL_VIDEODRIVER` selects the matching SDL backend. On a native Wayland session the stream opens in its own resizable window; on X11 it follows the Qt stream surface. Linux decode supports H.264, HEVC/H.265, and AV1 through Vulkan Video, CUDA/NVDEC, or FFmpeg software decode; native VA-API and V4L2 provide additional H.264 paths. Vulkan presentation is preferred, with SDL NV12 presentation as an independent fallback so decoder acceleration remains active if the Vulkan window path fails.
+## Packaging
-Linux production builds statically include FFmpeg, its H.264/HEVC/AV1 decoders, Opus, OpenH264, SDL, and their C/C++ support runtimes. They do not require system FFmpeg, GStreamer, Opus, SDL, VA-API, X11, Wayland, or Vulkan-loader libraries. Only the Linux base ABI and the selected display/audio/GPU driver interfaces remain system responsibilities. Building the bundled stack requires a C/C++ toolchain, CMake, Make, NASM, pkg-config, and Git. Native VA-API remains opt-in with `OPENNOW_NATIVE_LINUX_VAAPI=1` because it would add a host `libva` runtime dependency. `OPENNOW_NATIVE_VIDEO_BACKEND=auto|vulkan|cuda|nvdec|vaapi|v4l2|ffmpeg|software` controls decoder selection; `auto` prefers CUDA/NVDEC on NVIDIA, then Vulkan Video, VA-API, V4L2, and bundled FFmpeg software.
+The Qt CMake build always compiles `opennow-streamer-ffi` with Cargo's release profile and links the resulting shared library to `opennow-qt`. CPack installs that library beside the Qt executable and `opennow-core`; there is no separate streamer executable, helper application or native video window in the Qt package. Linux packages enable the bundled FFmpeg fallback while optional GPU driver interfaces remain dynamically discovered.
-## macOS validation
+The standalone `opennow-streamer` binary remains a development host and is not evidence for the packaged Qt presentation path.
-Run the checks natively on each architecture rather than treating a Linux cross-check as macOS proof:
+## Checks
```sh
-rustup target add aarch64-apple-darwin x86_64-apple-darwin
-OPENNOW_NATIVE_STREAMER_TARGET="$(rustc -vV | sed -n 's/^host: //p')" \
-OPENNOW_NATIVE_STREAMER_PLATFORM_KEY="darwin-$(uname -m | sed 's/arm64/arm64/;s/x86_64/x64/')" \
-npm --prefix opennow-stable run native:build
+cargo fmt --manifest-path native/opennow-streamer/Cargo.toml --all -- --check
+cargo clippy --manifest-path native/opennow-streamer/Cargo.toml --workspace --all-targets -- -D warnings
+cargo test --manifest-path native/opennow-streamer/Cargo.toml --workspace
```
-The CI package matrix runs this build on both Apple Silicon and Intel macOS runners before producing the DMG and ZIP. Release validation must additionally inspect the app bundle, verify its signature, launch the packaged child executable, and confirm fallback behavior on real hardware.
-
-The generated helper lives at `bin/darwin-/OpenNOWStreamer.app/Contents/MacOS/opennow-streamer`. Both that nested application and its executable are ad-hoc signed for local/unsigned builds; the release signing pass signs nested code inside-out before sealing the outer OpenNOW application.
-
-## Runtime validation
-
-Unit tests synthesize and decode video and Opus frames, verify drop-oldest queue behavior, and exercise pause/stop lifecycle. Linux hardware validation generates H.264, HEVC, and AV1 keyframes and decodes each through Vulkan Video and CUDA/NVDEC. Release validation must additionally run an authorized live session on Windows, Linux/X11, Linux/Wayland, Intel macOS, and Apple Silicon to validate NVST interoperability, A/V timing, Windows child-HWND behavior, paired-window ordering on the other platforms, native relative input, and device-specific audio output.
+Release validation must additionally run authorized live sessions on Windows, Linux/X11, Linux/Wayland, Intel macOS and Apple Silicon to validate NVST interoperability, native GPU import, audio, input, recovery and device-loss behavior.
diff --git a/native/opennow-streamer/crates/opennow-streamer-core/Cargo.toml b/native/opennow-streamer/crates/opennow-streamer-core/Cargo.toml
index 5a8516a77..69e824dcf 100644
--- a/native/opennow-streamer/crates/opennow-streamer-core/Cargo.toml
+++ b/native/opennow-streamer/crates/opennow-streamer-core/Cargo.toml
@@ -14,6 +14,3 @@ opennow-streamer-transport = { path = "../opennow-streamer-transport" }
rustls.workspace = true
serde_json.workspace = true
tungstenite.workspace = true
-
-[dev-dependencies]
-str0m.workspace = true
diff --git a/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs
index 878636af8..e3298c84e 100644
--- a/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs
+++ b/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::net::UdpSocket;
-use std::sync::mpsc::{Receiver, Sender};
+use std::sync::mpsc::{Receiver, Sender, SyncSender, TrySendError};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
@@ -8,21 +8,19 @@ use std::time::{Duration, Instant};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use opennow_streamer_platform::{
- CapturedInput, CapturedInputQueue, CapturedInputSample, EncodedFrame, EncodedMicrophoneQueue,
- MediaCodec, MediaColorQuality, MediaControl, MediaFeedback, MediaRuntime, MediaRuntimeControl,
- MediaSession, MediaSink, MediaStreamConfig, MediaVideoCodec, MicrophoneCapture, PushOutcome,
- RecordingSummary, StreamShortcutAction, StreamShortcutBindings, record_matroska,
+ CapturedInput, CapturedInputQueue, CapturedInputSample, EncodedFrame, MediaCodec,
+ MediaColorQuality, MediaControl, MediaFeedback, MediaRuntime, MediaRuntimeControl,
+ MediaSession, MediaSink, MediaStreamConfig, MediaVideoCodec, PushOutcome, RecordingSummary,
+ StreamShortcutAction, StreamShortcutBindings, embedded_video_backends, record_matroska,
supports_audio_decode, supports_audio_output, video_backends,
};
use opennow_streamer_protocol::{
Capabilities, Command, PROTOCOL_VERSION, SessionContext, error, event, response,
};
use opennow_streamer_transport::{
- EncodedMicrophoneFrame, NegotiatedVideoCodec, NvstDropReason, NvstReceiveEvent,
- NvstReceiverState, NvstRecovery, NvstUdpReceiverControl, NvstUdpReceiverSession,
- PreferredVideoTransport, ReservedNvstBundle, SharedNvstFeedback, TransportControl,
- TransportEvent, TransportSession, negotiate, reserve_nvst_mjolnir_udp_socket,
- select_preferred_video_transport, spawn_nvst_mjolnir_receiver,
+ NvstDropReason, NvstReceiveEvent, NvstReceiverState, NvstRecovery, NvstUdpReceiverControl,
+ NvstUdpReceiverSession, ReservedNvstBundle, SharedNvstFeedback, parse_nvst_video_handoff,
+ reserve_nvst_mjolnir_udp_socket, spawn_nvst_mjolnir_receiver,
spawn_nvst_udp_receiver_with_socket,
};
use serde_json::{Value, json};
@@ -33,11 +31,44 @@ use nvst_rtsp::{ActiveNvstRtspSession, prepare_owned_nvst};
pub use opennow_streamer_transport::{EncodedMediaFrame, MediaConsumer};
+#[derive(Clone)]
+pub struct EventSender {
+ inner: EventSenderInner,
+}
+
+#[derive(Clone)]
+enum EventSenderInner {
+ Unbounded(Sender),
+ Bounded(SyncSender),
+}
+
+impl EventSender {
+ fn unbounded(sender: Sender) -> Self {
+ Self {
+ inner: EventSenderInner::Unbounded(sender),
+ }
+ }
+
+ pub fn bounded(sender: SyncSender) -> Self {
+ Self {
+ inner: EventSenderInner::Bounded(sender),
+ }
+ }
+
+ fn send(&self, value: Value) -> Result<(), ()> {
+ match &self.inner {
+ EventSenderInner::Unbounded(sender) => sender.send(value).map_err(|_| ()),
+ EventSenderInner::Bounded(sender) => match sender.try_send(value) {
+ Ok(()) => Ok(()),
+ Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => Err(()),
+ },
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Idle,
- Prepared,
- Negotiating,
Connected,
}
@@ -107,21 +138,18 @@ impl NvstSessionResources for ActiveNvstResources {
pub struct Engine {
lifecycle: Arc>,
- transport: Option,
nvst_transport: Option,
nvst_mjolnir_transport: Option,
reserved_nvst_bundle: Option,
nvst_hole_punch_socket: Option,
nvst_rtsp: Option,
- events: Sender,
+ events: EventSender,
media_consumer: Option,
media_runtime: Option,
media_session: Option,
media_worker: Option>,
media_feedback: Option>,
feedback_worker: Option>,
- microphone_capture: Option,
- microphone_enabled: bool,
recording_worker: Option>>,
}
@@ -134,13 +162,16 @@ struct Lifecycle {
impl Engine {
pub fn new(events: Sender) -> Self {
+ Self::with_event_sender(EventSender::unbounded(events))
+ }
+
+ pub fn with_event_sender(events: EventSender) -> Self {
Self {
lifecycle: Arc::new(Mutex::new(Lifecycle {
state: State::Idle,
context: None,
generation: 0,
})),
- transport: None,
nvst_transport: None,
nvst_mjolnir_transport: None,
reserved_nvst_bundle: None,
@@ -153,20 +184,28 @@ impl Engine {
media_worker: None,
media_feedback: None,
feedback_worker: None,
- microphone_capture: None,
- microphone_enabled: false,
recording_worker: None,
}
}
+ pub fn embedded(events: EventSender) -> Self {
+ Self::with_event_sender(events)
+ }
+
pub fn with_media_consumer(events: Sender, media_consumer: MediaConsumer) -> Self {
+ Self::with_media_consumer_and_event_sender(EventSender::unbounded(events), media_consumer)
+ }
+
+ pub fn with_media_consumer_and_event_sender(
+ events: EventSender,
+ media_consumer: MediaConsumer,
+ ) -> Self {
Self {
lifecycle: Arc::new(Mutex::new(Lifecycle {
state: State::Idle,
context: None,
generation: 0,
})),
- transport: None,
nvst_transport: None,
nvst_mjolnir_transport: None,
reserved_nvst_bundle: None,
@@ -179,20 +218,24 @@ impl Engine {
media_worker: None,
media_feedback: None,
feedback_worker: None,
- microphone_capture: None,
- microphone_enabled: false,
recording_worker: None,
}
}
pub fn with_media_runtime(events: Sender, media_runtime: MediaRuntime) -> Self {
+ Self::with_media_runtime_and_event_sender(EventSender::unbounded(events), media_runtime)
+ }
+
+ pub fn with_media_runtime_and_event_sender(
+ events: EventSender,
+ media_runtime: MediaRuntime,
+ ) -> Self {
Self {
lifecycle: Arc::new(Mutex::new(Lifecycle {
state: State::Idle,
context: None,
generation: 0,
})),
- transport: None,
nvst_transport: None,
nvst_mjolnir_transport: None,
reserved_nvst_bundle: None,
@@ -205,12 +248,14 @@ impl Engine {
media_worker: None,
media_feedback: None,
feedback_worker: None,
- microphone_capture: None,
- microphone_enabled: false,
recording_worker: None,
}
}
+ pub fn with_embedded_media_runtime(events: EventSender, media_runtime: MediaRuntime) -> Self {
+ Self::with_media_runtime_and_event_sender(events, media_runtime)
+ }
+
pub fn handle(&mut self, command: Command) -> (Vec, bool) {
let id = command.id.clone();
let result = match command.kind.as_str() {
@@ -219,9 +264,6 @@ impl Engine {
"nvst-unbind" => self.nvst_unbind(command),
"nvst-send" => self.nvst_send(command),
"start" => self.start(command),
- "offer" => self.offer(command),
- "remote-ice" => self.remote_ice(command),
- "input" => self.input(command),
"input-paused" => self.set_paused(command),
"surface" => self.update_surface(command),
"stats-toggle" => Ok(vec![
@@ -238,7 +280,6 @@ impl Engine {
json!({"action":"toggle-fullscreen", "source":"command"}),
),
]),
- "microphone-toggle" => self.toggle_microphone(command),
"anti-afk-pulse" => self.anti_afk_pulse(command),
"recording-start" => self.start_recording(command),
"recording-stop" => self.stop_recording(command),
@@ -276,17 +317,20 @@ impl Engine {
format!("Native streamer requires protocol {PROTOCOL_VERSION}"),
));
}
- let backends = video_backends();
+ let backends = if self
+ .media_runtime
+ .as_ref()
+ .is_some_and(MediaRuntime::is_embedded)
+ {
+ embedded_video_backends()
+ } else {
+ video_backends()
+ };
let media_ready = self.media_runtime.is_some();
let video_ready = media_ready && backends.iter().any(|backend| backend.available);
let capabilities = Capabilities {
protocol_version: PROTOCOL_VERSION,
backend: "native",
- fallback_reason: (!media_ready)
- .then_some("Native streamer requires an in-process decoded media runtime"),
- supports_offer_answer: media_ready,
- supports_remote_ice: media_ready,
- supports_local_ice: media_ready,
supports_input: media_ready,
supports_video_decode: video_ready,
supports_video_present: video_ready,
@@ -295,14 +339,12 @@ impl Engine {
supports_owned_nvst_negotiation: media_ready,
video_backends: backends,
};
- let mut ready = json!({
+ let ready = json!({
"id": command.id,
"type": "ready",
"processId": std::process::id(),
"capabilities": capabilities,
});
- ready["capabilities"]["microphoneDevices"] =
- json!(MicrophoneCapture::device_names().unwrap_or_default());
Ok(vec![ready])
}
@@ -485,29 +527,23 @@ impl Engine {
format!("Session context is not serializable: {context_error}"),
)
})?;
- let explicit_nvst = transport_context
- .pointer("/settings/transportMode")
- .and_then(Value::as_str)
- .is_some_and(|mode| mode.eq_ignore_ascii_case("nvst"))
- || transport_context.get("nvstVideo").is_some()
- || transport_context.get("nvstTransport").is_some();
- let (nvst_config, fallback_note) =
- match select_preferred_video_transport(&transport_context) {
- PreferredVideoTransport::Nvst(config) => (Some(*config), None),
- PreferredVideoTransport::WebRtcFallback(reason) if explicit_nvst => {
- return Err(error(
- Some(&command.id),
- "invalid-nvst-handoff",
- format!("Explicit NVST transport is invalid: {reason:?}"),
- ));
- }
- PreferredVideoTransport::WebRtcFallback(reason) => (
- None,
- Some(format!(
- "NVST unavailable; using WebRTC fallback: {reason:?}"
- )),
- ),
- };
+ let nvst_config = match parse_nvst_video_handoff(&transport_context) {
+ Ok(Some(config)) => Some(config),
+ Ok(None) => {
+ return Err(error(
+ Some(&command.id),
+ "nvst-handoff-required",
+ "Native streaming requires an NVST handoff",
+ ));
+ }
+ Err(reason) => {
+ return Err(error(
+ Some(&command.id),
+ "invalid-nvst-handoff",
+ format!("NVST transport is invalid: {reason}"),
+ ));
+ }
+ };
let nvst_bundle_available = nvst_config
.as_ref()
.is_some_and(|config| config.remote_dtls_fingerprint().is_some());
@@ -515,9 +551,6 @@ impl Engine {
.as_ref()
.is_some_and(|config| config.audio_track().is_some());
- if let Some(transport) = self.transport.take() {
- transport.stop();
- }
if let Some(transport) = self.nvst_transport.take() {
transport.stop();
}
@@ -680,11 +713,7 @@ impl Engine {
let mut lifecycle = lock_lifecycle(&self.lifecycle);
lifecycle.generation = lifecycle.generation.wrapping_add(1);
lifecycle.context = Some(context);
- lifecycle.state = if nvst_events.is_some() {
- State::Connected
- } else {
- State::Prepared
- };
+ lifecycle.state = State::Connected;
lifecycle.generation
};
if let Some(nvst_events) = nvst_events {
@@ -751,341 +780,17 @@ impl Engine {
"status",
json!({
"status": "ready",
- "message": if self.nvst_transport.is_some() {
- "NVST authenticated H.264 receive path initialized"
- } else if self.media_runtime.is_some() {
- "H.264 video and Opus audio media path initialized"
- } else {
- "Native WebRTC session prepared"
- }
+ "message": "NVST authenticated media path initialized"
}),
));
- if let Some(note) = fallback_note {
- let _ = self
- .events
- .send(event("log", json!({ "level": "debug", "message": note })));
- }
let mut start_response = response(command.id, "ok");
- let using_nvst = self.nvst_transport.is_some();
- start_response["transport"] =
- Value::String(if using_nvst { "nvst" } else { "webrtc" }.to_owned());
- start_response["capabilities"] = if using_nvst {
- json!({
- "supportsOfferAnswer": false,
- "supportsRemoteIce": false,
- "supportsLocalIce": false,
- "supportsInput": nvst_bundle_available,
- "supportsAudioDecode": nvst_audio_negotiated && supports_audio_decode(),
- "supportsAudioOutput": nvst_audio_negotiated && supports_audio_output(),
- })
- } else {
- json!({
- "supportsOfferAnswer": self.media_runtime.is_some(),
- "supportsRemoteIce": self.media_runtime.is_some(),
- "supportsLocalIce": self.media_runtime.is_some(),
- "supportsInput": self.media_runtime.is_some(),
- "supportsAudioDecode": self.media_runtime.is_some() && supports_audio_decode(),
- "supportsAudioOutput": self.media_runtime.is_some() && supports_audio_output(),
- })
- };
- Ok(vec![start_response])
- }
-
- fn offer(&mut self, command: Command) -> Result, Value> {
- if self.nvst_transport.is_some() {
- return Err(error(
- Some(&command.id),
- "nvst-video-active",
- "NVST video is active; do not negotiate a WebRTC media offer for this session",
- ));
- }
- let offer_sdp = command.sdp.as_deref().ok_or_else(|| {
- error(
- Some(&command.id),
- "missing-sdp",
- "Offer command does not include SDP",
- )
- })?;
- let offered_context = command
- .context
- .map(|context| parse_context(Some(context), &command.id))
- .transpose()?;
- if let Some(context) = &offered_context {
- validate_context(context, &command.id)?;
- }
- let (context, generation) = {
- let mut lifecycle = lock_lifecycle(&self.lifecycle);
- if lifecycle.state == State::Idle {
- return Err(error(
- Some(&command.id),
- "not-started",
- "Start must be sent before offer",
- ));
- }
- if lifecycle.state != State::Prepared {
- return Err(invalid_state(
- &command.id,
- "offer",
- lifecycle.state,
- "Prepared",
- ));
- }
- let Some(stored_context) = lifecycle.context.as_ref() else {
- lifecycle.state = State::Idle;
- return Err(error(
- Some(&command.id),
- "invalid-state",
- "Prepared lifecycle is missing its session context",
- ));
- };
- let stored_session_id = stored_context.session.session_id.clone();
- if let Some(context) = offered_context {
- if context.session.session_id != stored_session_id {
- return Err(error(
- Some(&command.id),
- "session-mismatch",
- "Offer context does not match the prepared session",
- ));
- }
- lifecycle.context = Some(context);
- }
- let Some(context) = lifecycle.context.clone() else {
- lifecycle.state = State::Idle;
- return Err(error(
- Some(&command.id),
- "invalid-state",
- "Prepared lifecycle is missing its session context",
- ));
- };
- lifecycle.state = State::Negotiating;
- (context, lifecycle.generation)
- };
- let Some(media_consumer) = self.media_consumer.clone() else {
- let mut lifecycle = lock_lifecycle(&self.lifecycle);
- if lifecycle.generation == generation && lifecycle.state == State::Negotiating {
- lifecycle.state = State::Prepared;
- }
- return Err(error(
- Some(&command.id),
- "media-consumer-unavailable",
- "No in-process encoded media consumer is configured",
- ));
- };
- let threshold = partial_reliable_threshold(offer_sdp).unwrap_or(300);
- let video_codec = match media_stream_config(&context).codec {
- MediaVideoCodec::H264 => NegotiatedVideoCodec::H264,
- MediaVideoCodec::H265 => NegotiatedVideoCodec::H265,
- MediaVideoCodec::Av1 => NegotiatedVideoCodec::Av1,
- };
- let (transport_events, receiver) = std::sync::mpsc::channel();
- let negotiated = negotiate(
- offer_sdp,
- &context.session,
- video_codec,
- threshold,
- transport_events,
- media_consumer,
- )
- .map_err(|transport_error| {
- let mut lifecycle = lock_lifecycle(&self.lifecycle);
- if lifecycle.generation == generation && lifecycle.state == State::Negotiating {
- lifecycle.state = State::Prepared;
- }
- error(
- Some(&command.id),
- transport_error.code(),
- transport_error.to_string(),
- )
- })?;
- let output = self.events.clone();
- let lifecycle = self.lifecycle.clone();
- let transport_control = negotiated.session.control();
- let media_feedback = self.media_feedback.take();
- let captured_input = self
- .media_session
- .as_ref()
- .map(MediaSession::captured_input);
- let microphone_packets = match context
- .settings
- .get("microphoneMode")
- .and_then(Value::as_str)
- .unwrap_or("disabled")
- {
- "voice-activity" if self.media_runtime.is_some() => {
- let configured_device = context
- .settings
- .get("microphoneDeviceId")
- .and_then(Value::as_str)
- .filter(|value| !value.trim().is_empty());
- let capture = MicrophoneCapture::start(configured_device).or_else(|configured_error| {
- if configured_device.is_none() {
- return Err(configured_error);
- }
- let _ = self.events.send(event(
- "log",
- json!({"level":"warn","message":"Configured microphone is unavailable; using the system default"}),
- ));
- MicrophoneCapture::start(None).map_err(|fallback_error| {
- format!("configured microphone failed ({configured_error}); default microphone failed ({fallback_error})")
- })
- });
- match capture {
- Ok(capture) => {
- let packets = capture.packets();
- self.microphone_capture = Some(capture);
- self.microphone_enabled = true;
- Some(packets)
- }
- Err(message) => {
- let _ = self.events.send(event(
- "microphone-state",
- json!({"state":"unavailable","enabled":false,"message":message}),
- ));
- None
- }
- }
- }
- "push-to-talk" => {
- let _ = self.events.send(event(
- "microphone-state",
- json!({
- "state":"unavailable",
- "enabled":false,
- "message":"Push-to-talk requires dynamic shortcut negotiation and remains disabled"
- }),
- ));
- None
- }
- _ => None,
- };
- let shortcut_runtime = self.media_runtime.clone();
- let feedback_worker = thread::Builder::new()
- .name("opennow-media-events".to_owned())
- .spawn(move || {
- forward_session_events(
- &output,
- &lifecycle,
- generation,
- WebrtcSessionResources {
- transport_events: receiver,
- media_feedback,
- captured_input,
- microphone_packets,
- shortcut_runtime,
- transport: transport_control,
- },
- );
- });
- self.feedback_worker = Some(match feedback_worker {
- Ok(worker) => worker,
- Err(spawn_error) => {
- negotiated.session.stop();
- let mut lifecycle = lock_lifecycle(&self.lifecycle);
- if lifecycle.generation == generation && lifecycle.state == State::Negotiating {
- lifecycle.state = State::Prepared;
- }
- drop(lifecycle);
- self.stop_media_resources();
- return Err(error(
- Some(&command.id),
- "media-worker-failed",
- spawn_error.to_string(),
- ));
- }
+ start_response["transport"] = Value::String("nvst".to_owned());
+ start_response["capabilities"] = json!({
+ "supportsInput": nvst_bundle_available,
+ "supportsAudioDecode": nvst_audio_negotiated && supports_audio_decode(),
+ "supportsAudioOutput": nvst_audio_negotiated && supports_audio_output(),
});
- self.transport = Some(negotiated.session);
- let _ = self.events.send(event(
- "local-ice",
- json!({ "candidate": negotiated.local_candidate }),
- ));
- Ok(vec![json!({
- "id": command.id,
- "type": "answer",
- "answer": { "sdp": negotiated.answer_sdp },
- })])
- }
-
- fn remote_ice(&self, command: Command) -> Result, Value> {
- if self.nvst_transport.is_some() {
- return Err(error(
- Some(&command.id),
- "nvst-remote-ice-unsupported",
- "NVST owns its negotiated ICE bundle and does not accept remote-ice commands",
- ));
- }
- let state = lock_lifecycle(&self.lifecycle).state;
- if !matches!(state, State::Negotiating | State::Connected) {
- return Err(invalid_state(
- &command.id,
- "remote-ice",
- state,
- "Negotiating or Connected",
- ));
- }
- let transport = self.transport.as_ref().ok_or_else(|| {
- error(
- Some(&command.id),
- "transport-not-ready",
- "No active WebRTC transport",
- )
- })?;
- let candidate = command.candidate.as_ref().ok_or_else(|| {
- error(
- Some(&command.id),
- "missing-candidate",
- "Remote ICE command is empty",
- )
- })?;
- transport
- .add_remote_candidate(candidate)
- .map_err(|transport_error| {
- error(
- Some(&command.id),
- transport_error.code(),
- transport_error.to_string(),
- )
- })?;
- Ok(vec![response(command.id, "ok")])
- }
-
- fn input(&self, command: Command) -> Result, Value> {
- let state = lock_lifecycle(&self.lifecycle).state;
- if state != State::Connected {
- return Err(invalid_state(
- &command.id,
- "input",
- state,
- "Connected with an initialized input channel",
- ));
- }
- let input = command
- .input
- .as_ref()
- .ok_or_else(|| error(Some(&command.id), "missing-input", "Input command is empty"))?;
- let bytes = BASE64
- .decode(&input.payload_base64)
- .map_err(|decode_error| {
- error(Some(&command.id), "invalid-input", decode_error.to_string())
- })?;
- let send_result = if let Some(transport) = self.nvst_transport.as_ref() {
- transport.send_input(bytes, input.partially_reliable)
- } else if let Some(transport) = self.transport.as_ref() {
- transport.send_input(bytes, input.partially_reliable)
- } else {
- return Err(error(
- Some(&command.id),
- "transport-not-ready",
- "No active media transport",
- ));
- };
- send_result.map_err(|transport_error| {
- error(
- Some(&command.id),
- transport_error.code(),
- transport_error.to_string(),
- )
- })?;
- Ok(vec![response(command.id, "ok")])
+ Ok(vec![start_response])
}
fn set_paused(&self, command: Command) -> Result, Value> {
@@ -1170,9 +875,6 @@ impl Engine {
lifecycle.state = State::Idle;
was_active
};
- if let Some(transport) = self.transport.take() {
- transport.stop();
- }
if let Some(transport) = self.nvst_transport.take() {
transport.stop();
}
@@ -1195,8 +897,6 @@ impl Engine {
fn stop_media_resources(&mut self) {
let _ = self.stop_recording_inner();
- self.microphone_capture = None;
- self.microphone_enabled = false;
if self.media_runtime.is_some() {
self.media_consumer = None;
if let Some(session) = self.media_session.take() {
@@ -1300,36 +1000,6 @@ impl Engine {
}
}
- fn toggle_microphone(&mut self, command: Command) -> Result, Value> {
- let capture = self.microphone_capture.as_ref().ok_or_else(|| {
- error(
- Some(&command.id),
- "microphone-unavailable",
- "Microphone was not armed when this WebRTC session started",
- )
- })?;
- self.microphone_enabled = !self.microphone_enabled;
- capture.set_enabled(self.microphone_enabled);
- let message = if self.microphone_enabled {
- "Microphone is streaming"
- } else {
- "Microphone is muted"
- };
- let _ = self.events.send(event(
- "microphone-state",
- json!({
- "state":if self.microphone_enabled { "ready" } else { "disabled" },
- "enabled":self.microphone_enabled,
- "message":message
- }),
- ));
- Ok(vec![json!({
- "id":command.id,
- "type":"microphone-state",
- "enabled":self.microphone_enabled,
- })])
- }
-
fn anti_afk_pulse(&self, command: Command) -> Result, Value> {
let state = lock_lifecycle(&self.lifecycle).state;
if state != State::Connected {
@@ -1344,8 +1014,6 @@ impl Engine {
let bytes = captured_input_packet(input, 0);
if let Some(transport) = self.nvst_transport.as_ref() {
transport.send_input(bytes, false)
- } else if let Some(transport) = self.transport.as_ref() {
- transport.send_input(bytes, false)
} else {
Err(opennow_streamer_transport::TransportError::Closed)
}
@@ -1392,14 +1060,6 @@ impl Drop for Engine {
}
}
-fn partial_reliable_threshold(sdp: &str) -> Option {
- sdp.lines().find_map(|line| {
- line.trim()
- .strip_prefix("a=ri.partialReliableThresholdMs:")
- .and_then(|value| value.trim().parse().ok())
- })
-}
-
fn parse_context(context: Option, id: &str) -> Result {
let context = context.ok_or_else(|| {
error(
@@ -1439,18 +1099,6 @@ fn validate_context(context: &SessionContext, id: &str) -> Result<(), Value> {
"Session context settings and shortcuts must be objects",
));
}
- if context
- .session
- .ice_servers
- .iter()
- .any(|server| server.urls.is_empty() || server.urls.iter().any(|url| url.trim().is_empty()))
- {
- return Err(error(
- Some(id),
- "invalid-context",
- "Every ICE server requires at least one non-empty URL",
- ));
- }
if let Some(endpoint) = &context.session.media_connection_info {
if endpoint.ip.trim().is_empty() || endpoint.port == 0 || endpoint.port > u16::MAX.into() {
return Err(error(
@@ -1505,61 +1153,20 @@ fn lock_lifecycle(lifecycle: &Mutex) -> MutexGuard<'_, Lifecycle> {
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
-fn forward_transport_event(
- output: &Sender,
- lifecycle: &Mutex,
- generation: u64,
- transport_event: TransportEvent,
-) {
- {
- let mut lifecycle = lock_lifecycle(lifecycle);
- if lifecycle.generation != generation {
- return;
- }
- match &transport_event {
- TransportEvent::Connected => lifecycle.state = State::Connected,
- TransportEvent::Disconnected(_) => {
- lifecycle.context = None;
- lifecycle.state = State::Idle;
- }
- _ => {}
- }
- }
- let value = match transport_event {
- TransportEvent::Connected => event(
- "status",
- json!({ "status": "streaming", "message": "ICE, DTLS-SRTP, and RTP connected" }),
- ),
- TransportEvent::Disconnected(message) => {
- event("status", json!({ "status": "stopped", "message": message }))
- }
- TransportEvent::InputReady(protocol_version) => event(
- "input-ready",
- json!({ "protocolVersion": protocol_version }),
- ),
- TransportEvent::InputUnavailable(reason) => {
- event("input-unavailable", json!({ "reason": reason }))
- }
- TransportEvent::MicrophoneReady => event(
- "microphone-state",
- json!({"state":"ready","enabled":true,"message":"Microphone is streaming"}),
- ),
- TransportEvent::MicrophoneUnavailable(reason) => event(
- "microphone-state",
- json!({"state":"unavailable","enabled":false,"message":reason}),
- ),
- TransportEvent::Log(message) => {
- event("log", json!({ "level": "warn", "message": message }))
- }
- };
- let _ = output.send(value);
-}
-
fn forward_shortcut_action(
- output: &Sender,
+ output: &EventSender,
runtime: Option<&MediaRuntime>,
action: StreamShortcutAction,
) {
+ if action == StreamShortcutAction::TogglePointerLock
+ && runtime.is_some_and(MediaRuntime::is_embedded)
+ {
+ let _ = output.send(event(
+ "shortcut-action",
+ json!({"action":action.protocol_name(), "source":"keyboard"}),
+ ));
+ return;
+ }
let control = match action {
StreamShortcutAction::ToggleStats => None,
StreamShortcutAction::ToggleFullscreen => None,
@@ -1593,7 +1200,7 @@ struct NvstSessionEventResources {
}
fn forward_nvst_session_events(
- output: &Sender,
+ output: &EventSender,
lifecycle: &Mutex,
generation: u64,
event_resources: NvstSessionEventResources,
@@ -1714,7 +1321,7 @@ fn forward_nvst_session_events(
}
fn forward_nvst_event(
- output: &Sender,
+ output: &EventSender,
lifecycle: &Mutex,
generation: u64,
resources: &R,
@@ -1849,7 +1456,7 @@ fn forward_nvst_event(
}
fn attempt_nvst_recovery(
- output: &Sender,
+ output: &EventSender,
lifecycle: &Mutex,
generation: u64,
resources: &R,
@@ -1890,7 +1497,7 @@ fn attempt_nvst_recovery(
}
fn emit_nvst_terminal(
- output: &Sender,
+ output: &EventSender,
lifecycle: &Mutex,
generation: u64,
resources: &R,
@@ -1941,7 +1548,7 @@ impl NvstMediaFeedbackState {
}
fn forward_nvst_media_feedback(
- output: &Sender,
+ output: &EventSender,
lifecycle: &Mutex,
generation: u64,
resources: &R,
@@ -2348,7 +1955,7 @@ fn media_stream_config(context: &SessionContext) -> MediaStreamConfig {
}
fn consume_encoded_media(
- output: &Sender,
+ output: &EventSender,
receiver: Receiver,
sink: MediaSink,
) {
@@ -2392,252 +1999,6 @@ fn consume_encoded_media(
}
}
-struct WebrtcSessionResources {
- transport_events: Receiver,
- media_feedback: Option>,
- captured_input: Option>,
- microphone_packets: Option>,
- shortcut_runtime: Option,
- transport: TransportControl,
-}
-
-fn forward_session_events(
- output: &Sender,
- lifecycle: &Mutex,
- generation: u64,
- resources: WebrtcSessionResources,
-) {
- let WebrtcSessionResources {
- transport_events,
- media_feedback,
- captured_input,
- microphone_packets,
- shortcut_runtime,
- transport,
- } = resources;
- let mut drop_reports = HashMap::new();
- let input_origin = Instant::now();
- let mut input_available = false;
- let mut microphone_available = false;
- loop {
- if let Some(feedback) = media_feedback.as_ref() {
- while let Ok(feedback) = feedback.try_recv() {
- forward_media_feedback(
- output,
- lifecycle,
- generation,
- &transport,
- feedback,
- &mut drop_reports,
- );
- }
- }
- if let Some(captured_input) = captured_input.as_ref() {
- if !input_available {
- captured_input.clear();
- } else if captured_input.take_overflowed() {
- let _ = output.send(event(
- "error",
- json!({
- "code":"native-input-capture-overflow",
- "message":"Native input capture queue overflowed; input was paused to prevent stuck controls"
- }),
- ));
- input_available = false;
- } else {
- for _ in 0..32 {
- let Some(sample) = captured_input.take_sample() else {
- break;
- };
- if matches!(sample.input, CapturedInput::Guide) {
- let _ = output.send(event("overlay-request", json!({"source":"gamepad"})));
- continue;
- }
- if matches!(sample.input, CapturedInput::Screenshot) {
- let _ =
- output.send(event("screenshot-request", json!({"source":"keyboard"})));
- continue;
- }
- if matches!(sample.input, CapturedInput::RecordingToggle) {
- let _ = output.send(event(
- "recording-toggle-request",
- json!({"source":"keyboard"}),
- ));
- continue;
- }
- if let CapturedInput::Shortcut(action) = &sample.input {
- forward_shortcut_action(output, shortcut_runtime.as_ref(), *action);
- continue;
- }
- let captured = sample
- .captured_at
- .checked_duration_since(input_origin)
- .unwrap_or_default();
- let timestamp_us = u64::try_from(captured.as_micros()).unwrap_or(u64::MAX);
- if transport
- .send_input(captured_input_packet(sample.input, timestamp_us), false)
- .is_err()
- {
- input_available = false;
- break;
- }
- }
- }
- }
- if let Some(microphone_packets) = microphone_packets.as_ref() {
- if !microphone_available {
- microphone_packets.clear();
- } else {
- let dropped = microphone_packets.take_dropped();
- if dropped > 0 {
- let _ = output.send(event(
- "log",
- json!({
- "level":"warn",
- "message":format!("Dropped {dropped} microphone packets under load")
- }),
- ));
- }
- for _ in 0..4 {
- let Some(packet) = microphone_packets.take() else {
- break;
- };
- let _ = transport.send_microphone(EncodedMicrophoneFrame {
- payload: packet.payload,
- captured_at_us: packet.captured_at_us,
- audio_level_db: packet.audio_level_db,
- voice_activity: packet.voice_activity,
- });
- }
- }
- }
- match transport_events.recv_timeout(Duration::from_millis(5)) {
- Ok(transport_event) => {
- match &transport_event {
- TransportEvent::InputReady(_) => input_available = true,
- TransportEvent::InputUnavailable(_) | TransportEvent::Disconnected(_) => {
- input_available = false
- }
- TransportEvent::MicrophoneReady => microphone_available = true,
- TransportEvent::MicrophoneUnavailable(_) => microphone_available = false,
- _ => {}
- }
- let disconnected = matches!(transport_event, TransportEvent::Disconnected(_));
- forward_transport_event(output, lifecycle, generation, transport_event);
- if disconnected {
- break;
- }
- }
- Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
- Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
- }
- }
-}
-
-fn forward_media_feedback(
- output: &Sender,
- lifecycle: &Mutex,
- generation: u64,
- transport: &TransportControl,
- feedback: MediaFeedback,
- drop_reports: &mut HashMap<&'static str, QueueDropReport>,
-) {
- if lock_lifecycle(lifecycle).generation != generation {
- return;
- }
- match feedback {
- MediaFeedback::VideoFrameAccepted { .. } => {}
- MediaFeedback::PlaybackStarted { backend } => {
- let _ = output.send(event(
- "status",
- json!({
- "event": "first-frame",
- "backend": backend,
- "status": "streaming",
- "message": format!("{backend} presented the first video frame")
- }),
- ));
- }
- MediaFeedback::BackendFallback { from, to, reason } => {
- let _ = output.send(event(
- "log",
- json!({
- "event": "backend-fallback",
- "fromBackend": from,
- "toBackend": to,
- "reason": reason,
- "level": "warn",
- "message": format!("{from} startup failed; using {to}: {reason}")
- }),
- ));
- }
- MediaFeedback::RequestKeyframe { mid, reason } => {
- let request_result = transport.request_keyframe(mid);
- let _ = output.send(event(
- "log",
- json!({
- "event": "keyframe-request",
- "reason": reason,
- "level": if request_result.is_ok() { "info" } else { "warn" },
- "message": format!("Requested a video keyframe: {reason}")
- }),
- ));
- }
- MediaFeedback::DecoderError { codec, message } => {
- let _ = output.send(event(
- "error",
- json!({
- "event": "decoder-error",
- "codec": codec,
- "code": "media-decode-error",
- "message": format!("{codec} decoder error: {message}")
- }),
- ));
- }
- MediaFeedback::OutputError { message } => {
- let _ = output.send(event(
- "error",
- json!({ "event": "output-error", "code": "media-output-error", "message": message }),
- ));
- }
- MediaFeedback::DeviceLost {
- subsystem,
- recovered,
- message,
- } => {
- let _ = output.send(event(
- "log",
- json!({
- "event": "device-state",
- "subsystem": subsystem,
- "recovered": recovered,
- "level": if recovered { "info" } else { "warn" },
- "message": message.unwrap_or_else(|| format!(
- "{subsystem} device {}",
- if recovered { "recovered" } else { "was lost" }
- ))
- }),
- ));
- }
- MediaFeedback::QueueDropped { media, count } => {
- if let Some(dropped) = record_queue_drop(drop_reports, media, count) {
- let _ = output.send(event(
- "log",
- json!({
- "event": "queue-dropped",
- "media": media,
- "count": dropped,
- "level": "debug",
- "message": format!(
- "Low-latency {media} queues dropped {dropped} stale samples/frames"
- )
- }),
- ));
- }
- }
- }
-}
-
struct QueueDropReport {
dropped: usize,
started: Instant,
@@ -2667,8 +2028,6 @@ mod tests {
use std::net::UdpSocket;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
- use str0m::media::{Direction, MediaKind};
- use str0m::{Candidate, RtcConfig};
fn command(value: Value) -> Command {
serde_json::from_value(value).expect("command")
@@ -2693,19 +2052,6 @@ mod tests {
})
}
- fn synthetic_offer() -> String {
- opennow_streamer_transport::install_crypto();
- let mut offerer = RtcConfig::new().build(Instant::now());
- offerer.add_local_candidate(
- Candidate::host("127.0.0.1:49152".parse().expect("candidate address"), "udp")
- .expect("local candidate"),
- );
- let mut change = offerer.sdp_api();
- change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
- let (offer, _pending) = change.apply().expect("synthetic offer");
- offer.to_sdp_string()
- }
-
fn lifecycle_state(engine: &Engine) -> State {
lock_lifecycle(&engine.lifecycle).state
}
@@ -2713,11 +2059,7 @@ mod tests {
#[test]
fn shell_shortcuts_emit_typed_actions() {
let (sender, receiver) = std::sync::mpsc::channel();
- forward_shortcut_action(&sender, None, StreamShortcutAction::ToggleMicrophone);
- let action = receiver.recv().expect("shortcut action");
- assert_eq!(action["type"], "shortcut-action");
- assert_eq!(action["action"], "toggle-microphone");
-
+ let sender = EventSender::unbounded(sender);
forward_shortcut_action(&sender, None, StreamShortcutAction::ToggleStats);
let action = receiver.recv().expect("stats shortcut action");
assert_eq!(action["type"], "shortcut-action");
@@ -2820,6 +2162,7 @@ mod tests {
#[test]
fn decoder_keyframe_feedback_routes_to_nvst_pli_handle() {
let (sender, receiver) = std::sync::mpsc::channel();
+ let sender = EventSender::unbounded(sender);
let lifecycle = connected_lifecycle();
let resources = TestNvstResources::default();
let mut state = NvstMediaFeedbackState::new(true);
@@ -2849,6 +2192,7 @@ mod tests {
#[test]
fn accepted_video_keyframe_routes_pacing_feedback_and_resets_recovery_budget() {
let (sender, _receiver) = std::sync::mpsc::channel();
+ let sender = EventSender::unbounded(sender);
let lifecycle = connected_lifecycle();
let resources = TestNvstResources::default();
let mut state = NvstMediaFeedbackState::new(true);
@@ -2874,6 +2218,7 @@ mod tests {
#[test]
fn accepted_video_frames_emit_bounded_shell_telemetry() {
let (sender, receiver) = std::sync::mpsc::channel();
+ let sender = EventSender::unbounded(sender);
let lifecycle = connected_lifecycle();
let resources = TestNvstResources::default();
let mut state = NvstMediaFeedbackState::new(true);
@@ -3139,6 +2484,7 @@ mod tests {
#[test]
fn nvst_recovery_is_attempted_once_with_pli() {
let (sender, receiver) = std::sync::mpsc::channel();
+ let sender = EventSender::unbounded(sender);
let lifecycle = connected_lifecycle();
let resources = TestNvstResources::default();
let mut recovery_attempts = 0;
@@ -3170,6 +2516,7 @@ mod tests {
#[test]
fn repeated_packet_gaps_request_keyframes_without_stopping_the_session() {
let (sender, receiver) = std::sync::mpsc::channel();
+ let sender = EventSender::unbounded(sender);
let lifecycle = connected_lifecycle();
let resources = TestNvstResources::default();
let mut recovery_attempts = 0;
@@ -3203,6 +2550,7 @@ mod tests {
#[test]
fn transient_media_backpressure_requests_keyframe_without_stopping_session() {
let (sender, receiver) = std::sync::mpsc::channel();
+ let sender = EventSender::unbounded(sender);
let lifecycle = connected_lifecycle();
let resources = TestNvstResources::default();
let mut recovery_attempts = 0;
@@ -3230,6 +2578,7 @@ mod tests {
#[test]
fn exhausted_nvst_recovery_stops_every_leg_and_emits_terminal_status() {
let (sender, receiver) = std::sync::mpsc::channel();
+ let sender = EventSender::unbounded(sender);
let lifecycle = connected_lifecycle();
let resources = TestNvstResources::default();
let mut recovery_attempts = 0;
@@ -3277,6 +2626,7 @@ mod tests {
#[test]
fn assembled_keyframe_does_not_reset_recovery_episode_budget() {
let (sender, _receiver) = std::sync::mpsc::channel();
+ let sender = EventSender::unbounded(sender);
let lifecycle = connected_lifecycle();
let resources = TestNvstResources::default();
let mut recovery_attempts = 1;
@@ -3311,16 +2661,17 @@ mod tests {
}));
let (responses, _) = engine.handle(command);
assert_eq!(responses[0]["type"], "ready");
- assert_eq!(responses[0]["capabilities"]["supportsOfferAnswer"], false);
- assert_eq!(responses[0]["capabilities"]["supportsVideoPresent"], false);
- }
-
- #[test]
- fn extracts_partial_reliable_threshold() {
- assert_eq!(
- partial_reliable_threshold("v=0\r\na=ri.partialReliableThresholdMs:250\r\n"),
- Some(250),
+ assert!(
+ responses[0]["capabilities"]
+ .get("supportsOfferAnswer")
+ .is_none()
);
+ assert!(
+ responses[0]["capabilities"]
+ .get("supportsRemoteIce")
+ .is_none()
+ );
+ assert_eq!(responses[0]["capabilities"]["supportsVideoPresent"], false);
}
#[test]
@@ -3416,33 +2767,7 @@ mod tests {
}
#[test]
- fn start_validates_stores_context_and_prepares_session() {
- let (sender, receiver) = std::sync::mpsc::channel();
- let mut engine = Engine::new(sender);
- let context = synthetic_context("synthetic-session", json!([]));
- let start = command(json!({
- "id": "start",
- "type": "start",
- "context": context,
- }));
- let (responses, _) = engine.handle(start);
-
- assert_eq!(responses[0]["type"], "ok");
- assert_eq!(responses[0]["transport"], "webrtc");
- let lifecycle = lock_lifecycle(&engine.lifecycle);
- assert_eq!(lifecycle.state, State::Prepared);
- let stored = serde_json::to_value(lifecycle.context.as_ref().expect("stored context"))
- .expect("serializable stored context");
- assert_eq!(stored["session"]["sessionId"], "synthetic-session");
- assert_eq!(stored["session"]["syntheticExtension"], "preserved");
- assert_eq!(stored["syntheticContextExtension"], true);
- drop(lifecycle);
- let status = receiver.recv().expect("ready status");
- assert_eq!(status["status"], "ready");
- }
-
- #[test]
- fn valid_nvst_handoff_starts_udp_video_and_bypasses_webrtc_offer_negotiation() {
+ fn valid_nvst_handoff_starts_udp_video_and_rejects_removed_offer_command() {
let (sender, receiver) = std::sync::mpsc::channel();
let (media_sender, _media_receiver) = std::sync::mpsc::sync_channel(4);
let mut engine = Engine::with_media_consumer(sender, media_sender);
@@ -3464,13 +2789,20 @@ mod tests {
assert_eq!(responses[0]["type"], "ok");
assert_eq!(responses[0]["transport"], "nvst");
- assert_eq!(responses[0]["capabilities"]["supportsOfferAnswer"], false);
- assert_eq!(responses[0]["capabilities"]["supportsRemoteIce"], false);
+ assert!(
+ responses[0]["capabilities"]
+ .get("supportsOfferAnswer")
+ .is_none()
+ );
+ assert!(
+ responses[0]["capabilities"]
+ .get("supportsRemoteIce")
+ .is_none()
+ );
assert_eq!(responses[0]["capabilities"]["supportsInput"], false);
assert_eq!(responses[0]["capabilities"]["supportsAudioDecode"], false);
assert_eq!(lifecycle_state(&engine), State::Connected);
assert!(engine.nvst_transport.is_some());
- assert!(engine.transport.is_none());
assert!(receiver.try_iter().any(|message| {
message["type"] == "status"
&& message["message"]
@@ -3482,9 +2814,8 @@ mod tests {
"id": "offer",
"type": "offer",
"context": context,
- "sdp": synthetic_offer(),
})));
- assert_eq!(responses[0]["code"], "nvst-video-active");
+ assert_eq!(responses[0]["code"], "unknown-command");
let (responses, _) = engine.handle(command(json!({
"id": "stop",
@@ -3496,7 +2827,7 @@ mod tests {
}
#[test]
- fn explicit_invalid_nvst_handoff_fails_closed_without_webrtc_fallback() {
+ fn explicit_invalid_nvst_handoff_fails_closed() {
let (sender, _receiver) = std::sync::mpsc::channel();
let mut engine = Engine::new(sender);
let mut context = synthetic_context("invalid-nvst-session", json!([]));
@@ -3513,7 +2844,6 @@ mod tests {
assert_eq!(responses[0]["code"], "invalid-nvst-handoff");
assert_eq!(lifecycle_state(&engine), State::Idle);
- assert!(engine.transport.is_none());
assert!(engine.nvst_transport.is_none());
}
@@ -3532,7 +2862,6 @@ mod tests {
assert_eq!(responses[0]["code"], "missing-rtsps-endpoint");
assert_eq!(lifecycle_state(&engine), State::Idle);
- assert!(engine.transport.is_none());
assert!(engine.nvst_transport.is_none());
}
@@ -3559,7 +2888,7 @@ mod tests {
}
#[test]
- fn start_rejects_invalid_and_duplicate_sessions() {
+ fn start_rejects_invalid_contexts_and_missing_nvst_handoffs() {
let (sender, _receiver) = std::sync::mpsc::channel();
let mut engine = Engine::new(sender);
let invalid = command(json!({
@@ -3575,241 +2904,13 @@ mod tests {
assert_eq!(responses[0]["code"], "invalid-context");
assert_eq!(lifecycle_state(&engine), State::Idle);
- for id in ["first", "duplicate"] {
- let start = command(json!({
- "id": id,
- "type": "start",
- "context": synthetic_context("synthetic-session", json!([])),
- }));
- let (responses, _) = engine.handle(start);
- if id == "first" {
- assert_eq!(responses[0]["type"], "ok");
- } else {
- assert_eq!(responses[0]["code"], "invalid-state");
- }
- }
- }
-
- #[test]
- fn offer_negotiates_directly_with_configured_ice_services() {
- let (sender, _receiver) = std::sync::mpsc::channel();
- let (media_sender, _media_receiver) = std::sync::mpsc::sync_channel(4);
- let mut engine = Engine::with_media_consumer(sender, media_sender);
- let context = synthetic_context(
- "synthetic-session",
- json!([{
- "urls": ["stun:stun.synthetic.invalid:3478", "turn:turn.synthetic.invalid:3478"],
- "username": "synthetic-user",
- "credential": "synthetic-credential"
- }]),
- );
- let (responses, _) = engine.handle(command(json!({
- "id": "start",
- "type": "start",
- "context": context.clone(),
- })));
- assert_eq!(responses[0]["type"], "ok");
-
let (responses, _) = engine.handle(command(json!({
- "id": "offer",
- "type": "offer",
- "context": context,
- "sdp": synthetic_offer()
- })));
- assert_eq!(responses[0]["type"], "answer");
- assert!(
- responses[0]["answer"]["sdp"]
- .as_str()
- .is_some_and(|sdp| sdp.contains("m=video") && !sdp.contains("m=video 0"))
- );
- assert_eq!(lifecycle_state(&engine), State::Negotiating);
- }
-
- #[test]
- fn offer_fails_typed_when_no_in_process_media_consumer_exists() {
- let (sender, _receiver) = std::sync::mpsc::channel();
- let mut engine = Engine::new(sender);
- let context = synthetic_context("synthetic-session", json!([]));
- let (responses, _) = engine.handle(command(json!({
- "id": "start",
- "type": "start",
- "context": context.clone(),
- })));
- assert_eq!(responses[0]["type"], "ok");
-
- let (responses, _) = engine.handle(command(json!({
- "id": "offer",
- "type": "offer",
- "context": context,
- "sdp": "v=0\r\n"
- })));
-
- assert_eq!(responses[0]["code"], "media-consumer-unavailable");
- assert_eq!(lifecycle_state(&engine), State::Prepared);
- }
-
- #[test]
- fn prepared_session_negotiates_synthetic_offer_for_typed_media_consumer() {
- let (sender, receiver) = std::sync::mpsc::channel();
- let (media_sender, _media_receiver) = std::sync::mpsc::sync_channel(4);
- let mut engine = Engine::with_media_consumer(sender, media_sender);
- let context = synthetic_context("synthetic-session", json!([]));
- let (responses, _) = engine.handle(command(json!({
- "id": "start",
- "type": "start",
- "context": context.clone(),
- })));
- assert_eq!(responses[0]["type"], "ok");
-
- let (responses, _) = engine.handle(command(json!({
- "id": "offer",
- "type": "offer",
- "context": context,
- "sdp": synthetic_offer()
- })));
-
- assert_eq!(responses[0]["type"], "answer");
- assert!(
- responses[0]["answer"]["sdp"]
- .as_str()
- .is_some_and(|sdp| sdp.contains("m=video") && !sdp.contains("m=video 0"))
- );
- assert_eq!(lifecycle_state(&engine), State::Negotiating);
- assert!(
- receiver
- .try_iter()
- .any(|value| value["type"] == "local-ice")
- );
- }
-
- #[test]
- fn disconnect_clears_context_and_stale_disconnect_cannot_clear_new_session() {
- let (sender, receiver) = std::sync::mpsc::channel();
- let mut engine = Engine::new(sender.clone());
- let (responses, _) = engine.handle(command(json!({
- "id": "first",
- "type": "start",
- "context": synthetic_context("first-session", json!([])),
- })));
- assert_eq!(responses[0]["type"], "ok");
- let first_generation = lock_lifecycle(&engine.lifecycle).generation;
- forward_transport_event(
- &sender,
- &engine.lifecycle,
- first_generation,
- TransportEvent::Disconnected("synthetic disconnect".to_owned()),
- );
- {
- let lifecycle = lock_lifecycle(&engine.lifecycle);
- assert_eq!(lifecycle.state, State::Idle);
- assert!(lifecycle.context.is_none());
- }
-
- let (responses, _) = engine.handle(command(json!({
- "id": "second",
- "type": "start",
- "context": synthetic_context("second-session", json!([])),
- })));
- assert_eq!(responses[0]["type"], "ok");
- forward_transport_event(
- &sender,
- &engine.lifecycle,
- first_generation,
- TransportEvent::Disconnected("late stale disconnect".to_owned()),
- );
- let lifecycle = lock_lifecycle(&engine.lifecycle);
- assert_eq!(lifecycle.state, State::Prepared);
- assert_eq!(
- lifecycle
- .context
- .as_ref()
- .map(|value| value.session.session_id.as_str()),
- Some("second-session")
- );
- drop(lifecycle);
-
- let events = receiver.try_iter().collect::>();
- assert!(events.iter().any(|value| value["status"] == "stopped"));
- assert!(
- !events
- .iter()
- .any(|value| value["message"] == "late stale disconnect")
- );
- }
-
- #[test]
- fn encoded_media_consumer_is_typed_in_process_and_preserves_arc_payload() {
- let (sender, receiver) = std::sync::mpsc::channel();
- let (media_sender, media_receiver) = std::sync::mpsc::sync_channel(4);
- let mut engine = Engine::with_media_consumer(sender, media_sender);
- let (responses, _) = engine.handle(command(json!({
- "id": "start",
- "type": "start",
- "context": synthetic_context("synthetic-session", json!([])),
- })));
- assert_eq!(responses[0]["type"], "ok");
- let payload: Arc<[u8]> = Arc::from([1_u8, 2, 3]);
- engine
- .media_consumer
- .as_ref()
- .expect("media consumer")
- .send(EncodedMediaFrame {
- mid: "video-0".to_owned(),
- codec: "H264".to_owned(),
- payload: payload.clone(),
- rtp_timestamp: 90_000,
- clock_rate_hz: 90_000,
- channels: None,
- received_at_us: 1_500,
- keyframe: true,
- contiguous: true,
- })
- .expect("frame delivery");
-
- let frame = media_receiver.recv().expect("encoded frame");
- assert!(Arc::ptr_eq(&frame.payload, &payload));
- assert_eq!(frame.rtp_timestamp, 90_000);
- assert_eq!(frame.clock_rate_hz, 90_000);
- assert_eq!(frame.received_at_us, 1_500);
- assert!(
- receiver
- .try_iter()
- .all(|value| value["type"] != "encoded-media")
- );
- }
-
- #[test]
- fn unapplied_commands_are_rejected_and_stop_clears_context() {
- let (sender, _receiver) = std::sync::mpsc::channel();
- let mut engine = Engine::new(sender);
- let (responses, _) = engine.handle(command(json!({
- "id": "surface",
- "type": "surface",
- "surface": {}
- })));
- assert_eq!(responses[0]["code"], "unsupported-command");
-
- let (responses, _) = engine.handle(command(json!({
- "id": "start",
+ "id": "missing-nvst",
"type": "start",
"context": synthetic_context("synthetic-session", json!([])),
})));
- assert_eq!(responses[0]["type"], "ok");
- let (responses, _) = engine.handle(command(json!({
- "id": "shortcuts",
- "type": "update-shortcuts",
- "shortcuts": { "stopStream": "Ctrl+Alt+Q" }
- })));
- assert_eq!(responses[0]["code"], "unsupported-command");
- let (responses, _) = engine.handle(command(json!({
- "id": "stop",
- "type": "stop",
- "reason": "synthetic test complete"
- })));
- assert_eq!(responses[0]["type"], "ok");
- let lifecycle = lock_lifecycle(&engine.lifecycle);
- assert_eq!(lifecycle.state, State::Idle);
- assert!(lifecycle.context.is_none());
+ assert_eq!(responses[0]["code"], "nvst-handoff-required");
+ assert_eq!(lifecycle_state(&engine), State::Idle);
}
#[test]
diff --git a/native/opennow-streamer/crates/opennow-streamer-ffi/Cargo.toml b/native/opennow-streamer/crates/opennow-streamer-ffi/Cargo.toml
new file mode 100644
index 000000000..79b4f1935
--- /dev/null
+++ b/native/opennow-streamer/crates/opennow-streamer-ffi/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "opennow-streamer-ffi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[features]
+default = []
+linux-vaapi = ["opennow-streamer-platform/linux-vaapi"]
+linux-ffmpeg = ["opennow-streamer-platform/linux-ffmpeg"]
+linux-ffmpeg-bundled = ["opennow-streamer-platform/linux-ffmpeg-bundled"]
+
+[dependencies]
+opennow-streamer-core = { path = "../opennow-streamer-core" }
+opennow-streamer-platform = { path = "../opennow-streamer-platform" }
+opennow-streamer-protocol = { path = "../opennow-streamer-protocol" }
+serde_json.workspace = true
+
+[dev-dependencies]
+opennow-streamer-platform = { path = "../opennow-streamer-platform", features = ["test-runtime"] }
diff --git a/native/opennow-streamer/crates/opennow-streamer-ffi/README.md b/native/opennow-streamer/crates/opennow-streamer-ffi/README.md
new file mode 100644
index 000000000..266124aaa
--- /dev/null
+++ b/native/opennow-streamer/crates/opennow-streamer-ffi/README.md
@@ -0,0 +1,34 @@
+# OpenNOW streamer FFI
+
+This crate exposes `opennow-streamer-core::Engine` as a C-compatible in-process library. It is an integration boundary only: it does not start a child process, read or write standard streams, create an SDL/native window, or depend on Qt.
+
+## Contract
+
+- Initialize every `OpenNowStreamerConfig` field, set `abi_version` to `OPENNOW_STREAMER_FFI_ABI_VERSION`, and set `struct_size` to `sizeof(OpenNowStreamerConfig)`.
+- `opennow_streamer_create` writes one owned opaque handle to `output`. On failure it writes `NULL` when the supplied config header is readable.
+- `opennow_streamer_send` copies one serialized JSON protocol command before returning. `OPENNOW_STREAMER_OK` means the bounded command queue accepted it, not that the protocol command succeeded. Parse errors and protocol results arrive at `response_callback`.
+- Values returned directly by `Engine::handle` go to `response_callback`. Unsolicited engine events go to `event_callback`. Each byte slice is UTF-8 JSON and is valid only for the duration of that callback.
+- `frame_available_callback` is separate from the protocol callbacks. It only asks the shell to schedule a render; it carries no frame or protocol payload and may run on a decoder thread.
+- Response and event callbacks each run serially on their own dispatcher thread, so the two callbacks may overlap. They must return promptly and must not re-enter this handle. `user_data` must remain valid until `opennow_streamer_destroy` returns.
+- `opennow_streamer_destroy` consumes the handle exactly once, waits for the engine and both callback queues to drain, and then returns. No call may race with destroy. A null handle is rejected; reusing a destroyed pointer is caller-side undefined behavior.
+- Every exported function catches Rust panics before they can unwind through the C ABI. A worker-thread panic closes the command queue.
+
+All three queues are bounded. Command submission returns `OPENNOW_STREAMER_QUEUE_FULL` rather than blocking. Responses backpressure the engine worker so an accepted command's response is retained. Unsolicited events use a drop-newest policy when their queue is full because the engine's event path cannot block latency-sensitive transport workers.
+
+## GPU frame lifecycle
+
+The graphics API is GPU-only. It exposes no window, swap chain, `QWindow`, CPU image, pixel buffer, or encoded-video callback.
+
+1. On the QQuick render thread, call `opennow_streamer_set_graphics_context` with the versioned native objects borrowed from the current QRhi.
+2. When `frame_available_callback` schedules a frame, call `opennow_streamer_acquire_latest_frame`. The bounded mailbox contains one pending frame: publishing a newer frame releases the stale pending frame. A successful acquisition transfers one retained reference into the opaque token.
+3. After QRhi has opened the frame and provided its native command buffer, but **before** `QQuickRhiItem` calls `beginPass`, call `opennow_streamer_record_frame`. Conversion and synchronization are encoded into that exact command stream. The function never creates, submits, commits, or waits for another command buffer. It returns one producer-owned RGBA8 GPU texture for the selected in-flight slot.
+4. Import and sample that texture inside the item's render pass. Keep the token until QRhi has finished every GPU use of the slot, then call `opennow_streamer_release_frame`. A token records at most once and must be released exactly once.
+5. During scene-graph invalidation, release tokens first and call `opennow_streamer_scene_graph_shutdown` on the bound render thread before QRhi destroys its native objects. Destroy rejects a still-active scene graph instead of dropping GPU state from the wrong thread.
+
+Changing the graphics context clears the one-frame mailbox and advances its epoch. Previously acquired tokens stay releasable but become stale and cannot record against the replacement context. Graphics calls are bound to the thread that installed the active context; a new scene graph can bind a different thread after shutdown.
+
+## Integration boundary
+
+The public C constructor creates the embedded engine used by Qt. GPU producers publish through the platform runtime's `GraphicsFramePublisher`; the FFI owns the mailbox, token epochs, thread checks, and C ownership boundary while platform decoders own native texture creation and same-command-stream conversion.
+
+There is no callback cancellation or timeout. A callback that never returns will eventually backpressure responses and will make destroy wait indefinitely. Dropped unsolicited events are not yet summarized with an overflow event. The header is handwritten and must be validated by each C/C++ consumer's compile-time layout assertions.
diff --git a/native/opennow-streamer/crates/opennow-streamer-ffi/build.rs b/native/opennow-streamer/crates/opennow-streamer-ffi/build.rs
new file mode 100644
index 000000000..65313b1f0
--- /dev/null
+++ b/native/opennow-streamer/crates/opennow-streamer-ffi/build.rs
@@ -0,0 +1,15 @@
+fn main() {
+ match std::env::var("CARGO_CFG_TARGET_OS").as_deref() {
+ Ok("linux") => {
+ println!("cargo:rustc-link-arg=-Wl,-soname,libopennow_streamer_ffi.so");
+ }
+ Ok("macos") => {
+ println!("cargo:rustc-link-arg=-Wl,-install_name,@rpath/libopennow_streamer_ffi.dylib");
+ }
+ Ok("windows") if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") => {
+ println!("cargo:rustc-link-arg=/DELAYLOAD:mfplat.dll");
+ println!("cargo:rustc-link-lib=delayimp");
+ }
+ _ => {}
+ }
+}
diff --git a/native/opennow-streamer/crates/opennow-streamer-ffi/include/opennow_streamer_ffi.h b/native/opennow-streamer/crates/opennow-streamer-ffi/include/opennow_streamer_ffi.h
new file mode 100644
index 000000000..410a9518b
--- /dev/null
+++ b/native/opennow-streamer/crates/opennow-streamer-ffi/include/opennow_streamer_ffi.h
@@ -0,0 +1,215 @@
+#ifndef OPENNOW_STREAMER_FFI_H
+#define OPENNOW_STREAMER_FFI_H
+
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define OPENNOW_STREAMER_FFI_ABI_VERSION 2u
+#define OPENNOW_STREAMER_GRAPHICS_CONTEXT_VERSION 1u
+#define OPENNOW_STREAMER_RENDER_COMMAND_VERSION 1u
+
+#define OPENNOW_STREAMER_GRAPHICS_API_D3D11 1u
+#define OPENNOW_STREAMER_GRAPHICS_API_VULKAN 2u
+#define OPENNOW_STREAMER_GRAPHICS_API_METAL 3u
+
+#define OPENNOW_STREAMER_LOCAL_ACTION_GUIDE 1u
+#define OPENNOW_STREAMER_LOCAL_ACTION_SCREENSHOT 2u
+#define OPENNOW_STREAMER_LOCAL_ACTION_RECORDING_TOGGLE 3u
+
+typedef struct OpenNowStreamer OpenNowStreamer;
+typedef struct OpenNowStreamerFrame OpenNowStreamerFrame;
+
+typedef void (*OpenNowStreamerCallback)(
+ const uint8_t *bytes,
+ size_t length,
+ void *user_data);
+
+typedef void (*OpenNowStreamerFrameAvailableCallback)(void *user_data);
+
+typedef struct OpenNowStreamerConfig {
+ uint32_t abi_version;
+ size_t struct_size;
+ size_t command_queue_capacity;
+ size_t response_queue_capacity;
+ size_t event_queue_capacity;
+ size_t max_command_bytes;
+ OpenNowStreamerCallback response_callback;
+ OpenNowStreamerCallback event_callback;
+ OpenNowStreamerFrameAvailableCallback frame_available_callback;
+ OpenNowStreamerCallback cursor_callback;
+ void *user_data;
+} OpenNowStreamerConfig;
+
+/*
+ * Borrowed native objects from one live QRhi. The shell retains ownership.
+ *
+ * D3D11: device is ID3D11Device and queue is its immediate ID3D11DeviceContext.
+ * Vulkan: instance, physical_device, device, queue, and queue_family_index are required.
+ * Metal: device is id and queue is id.
+ */
+typedef struct OpenNowStreamerGraphicsContext {
+ uint32_t version;
+ size_t struct_size;
+ uint32_t graphics_api;
+ void *instance;
+ void *physical_device;
+ void *device;
+ void *queue;
+ uint32_t queue_family_index;
+} OpenNowStreamerGraphicsContext;
+
+/*
+ * One shell-owned in-flight slot and the current QRhi native command buffer.
+ * record_frame writes conversion/synchronization commands into this command stream; it never
+ * creates, commits, submits, or waits for another command buffer.
+ */
+typedef struct OpenNowStreamerRecordCommand {
+ uint32_t version;
+ size_t struct_size;
+ void *command_buffer;
+ uint32_t frame_slot;
+} OpenNowStreamerRecordCommand;
+
+typedef struct OpenNowStreamerFrameInfo {
+ uint32_t width;
+ uint32_t height;
+ uint64_t sequence;
+ uint64_t presentation_time_ns;
+} OpenNowStreamerFrameInfo;
+
+/*
+ * One RGBA8 GPU texture populated by record_frame. On D3D and Metal, resource is the native
+ * texture pointer encoded as uint64_t and resource_view is zero. On Vulkan, resource is VkImage
+ * and resource_view is VkImageView. The producer owns both handles; the frame token retains their
+ * backing slot until release.
+ */
+typedef struct OpenNowStreamerRecordedFrame {
+ uint64_t resource;
+ uint64_t resource_view;
+ uint32_t graphics_api;
+ uint32_t width;
+ uint32_t height;
+ uint32_t frame_slot;
+ uint64_t generation;
+ uint64_t presentation_time_ns;
+} OpenNowStreamerRecordedFrame;
+
+typedef enum OpenNowStreamerStatus {
+ OPENNOW_STREAMER_OK = 0,
+ OPENNOW_STREAMER_NULL_POINTER = 1,
+ OPENNOW_STREAMER_INVALID_CONFIG = 2,
+ OPENNOW_STREAMER_MESSAGE_TOO_LARGE = 3,
+ OPENNOW_STREAMER_QUEUE_FULL = 4,
+ OPENNOW_STREAMER_CLOSED = 5,
+ OPENNOW_STREAMER_NO_FRAME = 6,
+ OPENNOW_STREAMER_GRAPHICS_UNAVAILABLE = 7,
+ OPENNOW_STREAMER_WRONG_THREAD = 8,
+ OPENNOW_STREAMER_STALE_FRAME = 9,
+ OPENNOW_STREAMER_RENDER_FAILED = 10,
+ OPENNOW_STREAMER_SCENE_GRAPH_ACTIVE = 11,
+ OPENNOW_STREAMER_FRAME_ALREADY_RECORDED = 12,
+ OPENNOW_STREAMER_PANIC = 255
+} OpenNowStreamerStatus;
+
+OpenNowStreamerStatus opennow_streamer_create(
+ const OpenNowStreamerConfig *config,
+ OpenNowStreamer **output);
+
+OpenNowStreamerStatus opennow_streamer_send(
+ const OpenNowStreamer *handle,
+ const uint8_t *bytes,
+ size_t length);
+
+OpenNowStreamerStatus opennow_streamer_submit_key(
+ const OpenNowStreamer *handle,
+ uint16_t virtual_key,
+ uint16_t modifiers,
+ bool pressed);
+
+OpenNowStreamerStatus opennow_streamer_submit_mouse_relative(
+ const OpenNowStreamer *handle,
+ int16_t delta_x,
+ int16_t delta_y);
+
+OpenNowStreamerStatus opennow_streamer_submit_mouse_absolute(
+ const OpenNowStreamer *handle,
+ uint16_t x,
+ uint16_t y,
+ uint16_t width,
+ uint16_t height);
+
+OpenNowStreamerStatus opennow_streamer_submit_mouse_button(
+ const OpenNowStreamer *handle,
+ uint8_t button,
+ bool pressed);
+
+OpenNowStreamerStatus opennow_streamer_submit_mouse_wheel(
+ const OpenNowStreamer *handle,
+ int16_t delta_x,
+ int16_t delta_y);
+
+OpenNowStreamerStatus opennow_streamer_submit_gamepad(
+ const OpenNowStreamer *handle,
+ uint8_t controller_id,
+ uint16_t bitmap,
+ uint16_t buttons,
+ uint8_t left_trigger,
+ uint8_t right_trigger,
+ int16_t left_stick_x,
+ int16_t left_stick_y,
+ int16_t right_stick_x,
+ int16_t right_stick_y);
+
+OpenNowStreamerStatus opennow_streamer_submit_local_action(
+ const OpenNowStreamer *handle,
+ uint32_t action);
+
+OpenNowStreamerStatus opennow_streamer_set_capture_active(
+ const OpenNowStreamer *handle,
+ bool active,
+ bool relative_mouse,
+ uintptr_t window_handle,
+ bool *raw_input_active);
+
+OpenNowStreamerStatus opennow_streamer_set_graphics_context(
+ const OpenNowStreamer *handle,
+ const OpenNowStreamerGraphicsContext *context);
+
+OpenNowStreamerStatus opennow_streamer_acquire_latest_frame(
+ const OpenNowStreamer *handle,
+ OpenNowStreamerFrame **output,
+ OpenNowStreamerFrameInfo *info);
+
+/*
+ * Call after QRhi has opened its frame and supplied the native command buffer, but BEFORE
+ * QQuickRhiItem begins its render pass. The conversion may open its own offscreen pass or encode
+ * barriers, which is invalid inside the item's pass. After this returns, import/sample output's
+ * GPU texture in the QQuickRhiItem pass on the same command stream.
+ */
+OpenNowStreamerStatus opennow_streamer_record_frame(
+ const OpenNowStreamer *handle,
+ const OpenNowStreamerFrame *frame,
+ const OpenNowStreamerRecordCommand *command,
+ OpenNowStreamerRecordedFrame *output);
+
+OpenNowStreamerStatus opennow_streamer_release_frame(OpenNowStreamerFrame *frame);
+
+/*
+ * Call from the render thread during sceneGraphInvalidated/releaseResources, after releasing all
+ * frame tokens and before QRhi destroys the native objects from the graphics context.
+ */
+OpenNowStreamerStatus opennow_streamer_scene_graph_shutdown(
+ const OpenNowStreamer *handle);
+
+OpenNowStreamerStatus opennow_streamer_destroy(OpenNowStreamer *handle);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/native/opennow-streamer/crates/opennow-streamer-ffi/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-ffi/src/lib.rs
new file mode 100644
index 000000000..a3ecef9a6
--- /dev/null
+++ b/native/opennow-streamer/crates/opennow-streamer-ffi/src/lib.rs
@@ -0,0 +1,1573 @@
+use std::ffi::c_void;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::ptr;
+use std::sync::Arc;
+use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
+use std::thread::{self, JoinHandle};
+
+use opennow_streamer_core::{Engine, EventSender};
+use opennow_streamer_platform::{
+ CapturedInput, CapturedInputQueue, EmbeddedInputCapture, EmbeddedLocalAction, GraphicsApi,
+ GraphicsContext, GraphicsFramePublisher, GraphicsFrameToken, GraphicsRecordCommand,
+ GraphicsRecordedFrame, GraphicsRuntimeError, RenderThreadGraphics,
+ create_embedded_runtime_with_input,
+};
+use opennow_streamer_protocol::{Command, error};
+use serde_json::Value;
+
+pub const OPENNOW_STREAMER_FFI_ABI_VERSION: u32 = 2;
+const DEFAULT_MAX_COMMAND_BYTES: usize = 1024 * 1024;
+const MAX_QUEUE_CAPACITY: usize = 4096;
+const MAX_COMMAND_BYTES: usize = 16 * 1024 * 1024;
+
+pub type OpenNowStreamerCallback =
+ Option;
+pub type OpenNowStreamerFrameAvailableCallback =
+ Option;
+pub type OpenNowStreamerCursorCallback =
+ Option;
+
+#[repr(C)]
+#[derive(Clone, Copy)]
+pub struct OpenNowStreamerConfig {
+ pub abi_version: u32,
+ pub struct_size: usize,
+ pub command_queue_capacity: usize,
+ pub response_queue_capacity: usize,
+ pub event_queue_capacity: usize,
+ pub max_command_bytes: usize,
+ pub response_callback: OpenNowStreamerCallback,
+ pub event_callback: OpenNowStreamerCallback,
+ pub frame_available_callback: OpenNowStreamerFrameAvailableCallback,
+ pub cursor_callback: OpenNowStreamerCursorCallback,
+ pub user_data: *mut c_void,
+}
+
+#[repr(i32)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum OpenNowStreamerStatus {
+ Ok = 0,
+ NullPointer = 1,
+ InvalidConfig = 2,
+ MessageTooLarge = 3,
+ QueueFull = 4,
+ Closed = 5,
+ NoFrame = 6,
+ GraphicsUnavailable = 7,
+ WrongThread = 8,
+ StaleFrame = 9,
+ RenderFailed = 10,
+ SceneGraphActive = 11,
+ FrameAlreadyRecorded = 12,
+ Panic = 255,
+}
+
+pub const OPENNOW_STREAMER_GRAPHICS_CONTEXT_VERSION: u32 = 1;
+pub const OPENNOW_STREAMER_RENDER_COMMAND_VERSION: u32 = 1;
+pub const OPENNOW_STREAMER_GRAPHICS_API_D3D11: u32 = 1;
+pub const OPENNOW_STREAMER_GRAPHICS_API_VULKAN: u32 = 2;
+pub const OPENNOW_STREAMER_GRAPHICS_API_METAL: u32 = 3;
+pub const OPENNOW_STREAMER_LOCAL_ACTION_GUIDE: u32 = 1;
+pub const OPENNOW_STREAMER_LOCAL_ACTION_SCREENSHOT: u32 = 2;
+pub const OPENNOW_STREAMER_LOCAL_ACTION_RECORDING_TOGGLE: u32 = 3;
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy)]
+pub struct OpenNowStreamerGraphicsContext {
+ pub version: u32,
+ pub struct_size: usize,
+ pub graphics_api: u32,
+ pub instance: *mut c_void,
+ pub physical_device: *mut c_void,
+ pub device: *mut c_void,
+ pub queue: *mut c_void,
+ pub queue_family_index: u32,
+}
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy)]
+pub struct OpenNowStreamerRecordCommand {
+ pub version: u32,
+ pub struct_size: usize,
+ pub command_buffer: *mut c_void,
+ pub frame_slot: u32,
+}
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct OpenNowStreamerFrameInfo {
+ pub width: u32,
+ pub height: u32,
+ pub sequence: u64,
+ pub presentation_time_ns: u64,
+}
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct OpenNowStreamerRecordedFrame {
+ pub resource: u64,
+ pub resource_view: u64,
+ pub graphics_api: u32,
+ pub width: u32,
+ pub height: u32,
+ pub frame_slot: u32,
+ pub generation: u64,
+ pub presentation_time_ns: u64,
+}
+
+pub struct OpenNowStreamerFrame {
+ token: GraphicsFrameToken,
+}
+
+#[derive(Clone, Copy)]
+struct Callback {
+ function: OpenNowStreamerCallback,
+ user_data: usize,
+}
+
+#[derive(Clone, Copy)]
+struct FrameAvailableCallback {
+ function: OpenNowStreamerFrameAvailableCallback,
+ user_data: usize,
+}
+
+impl FrameAvailableCallback {
+ fn invoke(self) {
+ let Some(function) = self.function else {
+ return;
+ };
+ unsafe {
+ function(self.user_data as *mut c_void);
+ }
+ }
+}
+
+impl Callback {
+ fn invoke(self, value: &Value) {
+ let Some(function) = self.function else {
+ return;
+ };
+ let Ok(bytes) = serde_json::to_vec(value) else {
+ return;
+ };
+ unsafe {
+ function(bytes.as_ptr(), bytes.len(), self.user_data as *mut c_void);
+ }
+ }
+
+ fn invoke_bytes(self, bytes: &[u8]) {
+ let Some(function) = self.function else {
+ return;
+ };
+ unsafe {
+ function(bytes.as_ptr(), bytes.len(), self.user_data as *mut c_void);
+ }
+ }
+}
+
+enum WorkerCommand {
+ Send(Vec),
+ Destroy,
+}
+
+pub struct OpenNowStreamer {
+ commands: Option>,
+ worker: Option>,
+ response_dispatcher: Option>,
+ event_dispatcher: Option>,
+ cursor_dispatcher: Option>,
+ max_command_bytes: usize,
+ graphics: RenderThreadGraphics,
+ frame_publisher: GraphicsFramePublisher,
+ input: EmbeddedInputCapture,
+}
+
+impl OpenNowStreamer {
+ fn create(
+ config: OpenNowStreamerConfig,
+ captured_input: Arc,
+ engine_factory: impl FnOnce(EventSender, GraphicsFramePublisher, SyncSender>) -> Engine
+ + Send
+ + 'static,
+ exit_hook: impl FnOnce() + Send + 'static,
+ ) -> Result {
+ validate_config(&config)?;
+ let callback = |function| Callback {
+ function,
+ user_data: config.user_data as usize,
+ };
+ let frame_available = FrameAvailableCallback {
+ function: config.frame_available_callback,
+ user_data: config.user_data as usize,
+ };
+ let (graphics, frame_publisher) =
+ RenderThreadGraphics::new(move || frame_available.invoke());
+ let engine_frame_publisher = frame_publisher.clone();
+ let (commands, command_receiver) = sync_channel(config.command_queue_capacity);
+ let (responses, response_receiver) = sync_channel(config.response_queue_capacity);
+ let (events, event_receiver) = sync_channel(config.event_queue_capacity);
+ let (cursor_updates, cursor_receiver) = sync_channel(config.event_queue_capacity);
+ let response_dispatcher = spawn_dispatcher(
+ "opennow-ffi-responses",
+ response_receiver,
+ callback(config.response_callback),
+ )?;
+ let event_dispatcher = match spawn_dispatcher(
+ "opennow-ffi-events",
+ event_receiver,
+ callback(config.event_callback),
+ ) {
+ Ok(dispatcher) => dispatcher,
+ Err(status) => {
+ drop(responses);
+ let _ = response_dispatcher.join();
+ return Err(status);
+ }
+ };
+ let cursor_dispatcher =
+ match spawn_cursor_dispatcher(cursor_receiver, callback(config.cursor_callback)) {
+ Ok(dispatcher) => dispatcher,
+ Err(status) => {
+ drop(responses);
+ drop(events);
+ let _ = response_dispatcher.join();
+ let _ = event_dispatcher.join();
+ return Err(status);
+ }
+ };
+ let worker = match thread::Builder::new()
+ .name("opennow-ffi-engine".to_owned())
+ .spawn(move || {
+ let _ = catch_unwind(AssertUnwindSafe(|| {
+ run_engine(
+ command_receiver,
+ responses,
+ EventSender::bounded(events),
+ move |events| {
+ engine_factory(events, engine_frame_publisher, cursor_updates)
+ },
+ );
+ }));
+ exit_hook();
+ }) {
+ Ok(worker) => worker,
+ Err(_) => {
+ drop(commands);
+ let _ = response_dispatcher.join();
+ let _ = event_dispatcher.join();
+ let _ = cursor_dispatcher.join();
+ return Err(OpenNowStreamerStatus::Closed);
+ }
+ };
+ Ok(Self {
+ commands: Some(commands),
+ worker: Some(worker),
+ response_dispatcher: Some(response_dispatcher),
+ event_dispatcher: Some(event_dispatcher),
+ cursor_dispatcher: Some(cursor_dispatcher),
+ max_command_bytes: if config.max_command_bytes == 0 {
+ DEFAULT_MAX_COMMAND_BYTES
+ } else {
+ config.max_command_bytes
+ },
+ graphics,
+ frame_publisher,
+ input: EmbeddedInputCapture::new(captured_input),
+ })
+ }
+
+ fn send(&self, bytes: &[u8]) -> OpenNowStreamerStatus {
+ if bytes.len() > self.max_command_bytes {
+ return OpenNowStreamerStatus::MessageTooLarge;
+ }
+ let Some(commands) = self.commands.as_ref() else {
+ return OpenNowStreamerStatus::Closed;
+ };
+ match commands.try_send(WorkerCommand::Send(bytes.to_vec())) {
+ Ok(()) => OpenNowStreamerStatus::Ok,
+ Err(TrySendError::Full(_)) => OpenNowStreamerStatus::QueueFull,
+ Err(TrySendError::Disconnected(_)) => OpenNowStreamerStatus::Closed,
+ }
+ }
+
+ fn shutdown(&mut self) {
+ if let Some(commands) = self.commands.take() {
+ let _ = commands.send(WorkerCommand::Destroy);
+ }
+ if let Some(worker) = self.worker.take() {
+ let _ = worker.join();
+ }
+ if let Some(dispatcher) = self.response_dispatcher.take() {
+ let _ = dispatcher.join();
+ }
+ if let Some(dispatcher) = self.event_dispatcher.take() {
+ let _ = dispatcher.join();
+ }
+ if let Some(dispatcher) = self.cursor_dispatcher.take() {
+ let _ = dispatcher.join();
+ }
+ }
+
+ #[cfg(test)]
+ fn frame_publisher(&self) -> GraphicsFramePublisher {
+ self.frame_publisher.clone()
+ }
+}
+
+impl Drop for OpenNowStreamer {
+ fn drop(&mut self) {
+ self.shutdown();
+ }
+}
+
+fn validate_config(config: &OpenNowStreamerConfig) -> Result<(), OpenNowStreamerStatus> {
+ let valid_capacity = |capacity| (1..=MAX_QUEUE_CAPACITY).contains(&capacity);
+ if config.abi_version != OPENNOW_STREAMER_FFI_ABI_VERSION
+ || config.struct_size < size_of::()
+ || !valid_capacity(config.command_queue_capacity)
+ || !valid_capacity(config.response_queue_capacity)
+ || !valid_capacity(config.event_queue_capacity)
+ || config.max_command_bytes > MAX_COMMAND_BYTES
+ || config.response_callback.is_none()
+ {
+ return Err(OpenNowStreamerStatus::InvalidConfig);
+ }
+ Ok(())
+}
+
+fn spawn_dispatcher(
+ name: &str,
+ receiver: Receiver,
+ callback: Callback,
+) -> Result, OpenNowStreamerStatus> {
+ thread::Builder::new()
+ .name(name.to_owned())
+ .spawn(move || {
+ while let Ok(value) = receiver.recv() {
+ callback.invoke(&value);
+ }
+ })
+ .map_err(|_| OpenNowStreamerStatus::Closed)
+}
+
+fn spawn_cursor_dispatcher(
+ receiver: Receiver>,
+ callback: Callback,
+) -> Result, OpenNowStreamerStatus> {
+ thread::Builder::new()
+ .name("opennow-ffi-cursor".to_owned())
+ .spawn(move || {
+ while let Ok(bytes) = receiver.recv() {
+ callback.invoke_bytes(&bytes);
+ }
+ })
+ .map_err(|_| OpenNowStreamerStatus::Closed)
+}
+
+fn run_engine(
+ commands: Receiver,
+ responses: SyncSender,
+ events: EventSender,
+ engine_factory: impl FnOnce(EventSender) -> Engine,
+) {
+ let mut engine = engine_factory(events);
+ while let Ok(command) = commands.recv() {
+ let bytes = match command {
+ WorkerCommand::Send(bytes) => bytes,
+ WorkerCommand::Destroy => break,
+ };
+ let command: Command = match serde_json::from_slice(&bytes) {
+ Ok(command) => command,
+ Err(parse_error) => {
+ if responses
+ .send(error(None, "invalid-command", parse_error.to_string()))
+ .is_err()
+ {
+ break;
+ }
+ continue;
+ }
+ };
+ let (messages, keep_running) = engine.handle(command);
+ for message in messages {
+ if responses.send(message).is_err() {
+ return;
+ }
+ }
+ if !keep_running {
+ break;
+ }
+ }
+}
+
+fn graphics_context(
+ context: OpenNowStreamerGraphicsContext,
+) -> Result {
+ if context.version != OPENNOW_STREAMER_GRAPHICS_CONTEXT_VERSION
+ || context.struct_size < size_of::()
+ {
+ return Err(OpenNowStreamerStatus::InvalidConfig);
+ }
+ let api = match context.graphics_api {
+ OPENNOW_STREAMER_GRAPHICS_API_D3D11 => GraphicsApi::D3d11,
+ OPENNOW_STREAMER_GRAPHICS_API_VULKAN => GraphicsApi::Vulkan,
+ OPENNOW_STREAMER_GRAPHICS_API_METAL => GraphicsApi::Metal,
+ _ => return Err(OpenNowStreamerStatus::InvalidConfig),
+ };
+ Ok(GraphicsContext {
+ api,
+ instance: context.instance as usize,
+ physical_device: context.physical_device as usize,
+ device: context.device as usize,
+ queue: context.queue as usize,
+ queue_family_index: context.queue_family_index,
+ })
+}
+
+fn render_command(
+ command: OpenNowStreamerRecordCommand,
+) -> Result {
+ if command.version != OPENNOW_STREAMER_RENDER_COMMAND_VERSION
+ || command.struct_size < size_of::()
+ {
+ return Err(OpenNowStreamerStatus::InvalidConfig);
+ }
+ Ok(GraphicsRecordCommand {
+ command_buffer: command.command_buffer as usize,
+ frame_slot: command.frame_slot,
+ })
+}
+
+fn recorded_frame(
+ api: GraphicsApi,
+ frame: GraphicsRecordedFrame,
+) -> Result {
+ if frame.resource == 0 || frame.width == 0 || frame.height == 0 {
+ return Err(OpenNowStreamerStatus::RenderFailed);
+ }
+ Ok(OpenNowStreamerRecordedFrame {
+ resource: frame.resource,
+ resource_view: frame.resource_view,
+ graphics_api: match api {
+ GraphicsApi::D3d11 => OPENNOW_STREAMER_GRAPHICS_API_D3D11,
+ GraphicsApi::Vulkan => OPENNOW_STREAMER_GRAPHICS_API_VULKAN,
+ GraphicsApi::Metal => OPENNOW_STREAMER_GRAPHICS_API_METAL,
+ },
+ width: frame.width,
+ height: frame.height,
+ frame_slot: frame.frame_slot,
+ generation: frame.generation,
+ presentation_time_ns: frame.presentation_time_ns,
+ })
+}
+
+fn graphics_status(error: GraphicsRuntimeError) -> OpenNowStreamerStatus {
+ match error {
+ GraphicsRuntimeError::InvalidContext(_) | GraphicsRuntimeError::InvalidRenderCommand(_) => {
+ OpenNowStreamerStatus::InvalidConfig
+ }
+ GraphicsRuntimeError::SceneGraphInactive => OpenNowStreamerStatus::GraphicsUnavailable,
+ GraphicsRuntimeError::WrongThread => OpenNowStreamerStatus::WrongThread,
+ GraphicsRuntimeError::NoFrame => OpenNowStreamerStatus::NoFrame,
+ GraphicsRuntimeError::StaleFrame => OpenNowStreamerStatus::StaleFrame,
+ GraphicsRuntimeError::FrameAlreadyRecorded => OpenNowStreamerStatus::FrameAlreadyRecorded,
+ GraphicsRuntimeError::RecordFailed(_) => OpenNowStreamerStatus::RenderFailed,
+ }
+}
+
+fn ffi_status(body: impl FnOnce() -> OpenNowStreamerStatus) -> OpenNowStreamerStatus {
+ catch_unwind(AssertUnwindSafe(body)).unwrap_or(OpenNowStreamerStatus::Panic)
+}
+
+#[unsafe(no_mangle)]
+/// Creates one engine handle owned by the caller.
+///
+/// # Safety
+///
+/// `config` must point to a readable configuration whose first `struct_size` bytes remain valid
+/// for this call. `output` must point to writable storage for one handle pointer. Callback pointers
+/// and `user_data` must remain valid until destroy returns.
+pub unsafe extern "C" fn opennow_streamer_create(
+ config: *const OpenNowStreamerConfig,
+ output: *mut *mut OpenNowStreamer,
+) -> OpenNowStreamerStatus {
+ match catch_unwind(AssertUnwindSafe(|| {
+ if config.is_null() || output.is_null() {
+ return OpenNowStreamerStatus::NullPointer;
+ }
+ unsafe {
+ output.write(ptr::null_mut());
+ }
+ let abi_version = unsafe { ptr::addr_of!((*config).abi_version).read() };
+ let struct_size = unsafe { ptr::addr_of!((*config).struct_size).read() };
+ if abi_version != OPENNOW_STREAMER_FFI_ABI_VERSION
+ || struct_size < size_of::()
+ {
+ return OpenNowStreamerStatus::InvalidConfig;
+ }
+ let config = unsafe { config.read() };
+ let captured_input = Arc::new(CapturedInputQueue::default());
+ let runtime_input = Arc::clone(&captured_input);
+ match OpenNowStreamer::create(
+ config,
+ captured_input,
+ move |events, frames, cursor_updates| {
+ let cursor_update = Arc::new(move |bytes: Vec| {
+ let _ = cursor_updates.try_send(bytes);
+ });
+ Engine::with_embedded_media_runtime(
+ events,
+ create_embedded_runtime_with_input(frames, runtime_input, Some(cursor_update)),
+ )
+ },
+ || {},
+ ) {
+ Ok(handle) => {
+ unsafe {
+ output.write(Box::into_raw(Box::new(handle)));
+ }
+ OpenNowStreamerStatus::Ok
+ }
+ Err(status) => status,
+ }
+ })) {
+ Ok(status) => status,
+ Err(_) => OpenNowStreamerStatus::Panic,
+ }
+}
+
+#[unsafe(no_mangle)]
+/// Submits one keyboard transition to the embedded input queue.
+///
+/// # Safety
+///
+/// `handle` must be null or point to a live engine handle that is not being destroyed.
+pub unsafe extern "C" fn opennow_streamer_submit_key(
+ handle: *const OpenNowStreamer,
+ virtual_key: u16,
+ modifiers: u16,
+ pressed: bool,
+) -> OpenNowStreamerStatus {
+ ffi_status(|| {
+ let Some(handle) = (unsafe { handle.as_ref() }) else {
+ return OpenNowStreamerStatus::NullPointer;
+ };
+ handle.input.submit(CapturedInput::Key {
+ virtual_key,
+ modifiers,
+ pressed,
+ });
+ OpenNowStreamerStatus::Ok
+ })
+}
+
+#[unsafe(no_mangle)]
+/// Submits one relative mouse movement to the embedded input queue.
+///
+/// # Safety
+///
+/// `handle` must be null or point to a live engine handle that is not being destroyed.
+pub unsafe extern "C" fn opennow_streamer_submit_mouse_relative(
+ handle: *const OpenNowStreamer,
+ delta_x: i16,
+ delta_y: i16,
+) -> OpenNowStreamerStatus {
+ ffi_status(|| {
+ let Some(handle) = (unsafe { handle.as_ref() }) else {
+ return OpenNowStreamerStatus::NullPointer;
+ };
+ handle
+ .input
+ .submit(CapturedInput::MouseMove { delta_x, delta_y });
+ OpenNowStreamerStatus::Ok
+ })
+}
+
+#[unsafe(no_mangle)]
+/// Submits one absolute mouse position to the embedded input queue.
+///
+/// # Safety
+///
+/// `handle` must be null or point to a live engine handle that is not being destroyed.
+pub unsafe extern "C" fn opennow_streamer_submit_mouse_absolute(
+ handle: *const OpenNowStreamer,
+ x: u16,
+ y: u16,
+ width: u16,
+ height: u16,
+) -> OpenNowStreamerStatus {
+ ffi_status(|| {
+ let Some(handle) = (unsafe { handle.as_ref() }) else {
+ return OpenNowStreamerStatus::NullPointer;
+ };
+ if width == 0 || height == 0 {
+ return OpenNowStreamerStatus::InvalidConfig;
+ }
+ handle.input.submit(CapturedInput::MouseAbsolute {
+ x,
+ y,
+ width,
+ height,
+ });
+ OpenNowStreamerStatus::Ok
+ })
+}
+
+#[unsafe(no_mangle)]
+/// Submits one mouse-button transition to the embedded input queue.
+///
+/// # Safety
+///
+/// `handle` must be null or point to a live engine handle that is not being destroyed.
+pub unsafe extern "C" fn opennow_streamer_submit_mouse_button(
+ handle: *const OpenNowStreamer,
+ button: u8,
+ pressed: bool,
+) -> OpenNowStreamerStatus {
+ ffi_status(|| {
+ let Some(handle) = (unsafe { handle.as_ref() }) else {
+ return OpenNowStreamerStatus::NullPointer;
+ };
+ if !(1..=5).contains(&button) {
+ return OpenNowStreamerStatus::InvalidConfig;
+ }
+ handle
+ .input
+ .submit(CapturedInput::MouseButton { button, pressed });
+ OpenNowStreamerStatus::Ok
+ })
+}
+
+#[unsafe(no_mangle)]
+/// Submits one mouse-wheel movement to the embedded input queue.
+///
+/// # Safety
+///
+/// `handle` must be null or point to a live engine handle that is not being destroyed.
+pub unsafe extern "C" fn opennow_streamer_submit_mouse_wheel(
+ handle: *const OpenNowStreamer,
+ delta_x: i16,
+ delta_y: i16,
+) -> OpenNowStreamerStatus {
+ ffi_status(|| {
+ let Some(handle) = (unsafe { handle.as_ref() }) else {
+ return OpenNowStreamerStatus::NullPointer;
+ };
+ handle
+ .input
+ .submit(CapturedInput::MouseWheel { delta_x, delta_y });
+ OpenNowStreamerStatus::Ok
+ })
+}
+
+#[unsafe(no_mangle)]
+/// Submits one gamepad state snapshot to the embedded input queue.
+///
+/// # Safety
+///
+/// `handle` must be null or point to a live engine handle that is not being destroyed.
+pub unsafe extern "C" fn opennow_streamer_submit_gamepad(
+ handle: *const OpenNowStreamer,
+ controller_id: u8,
+ bitmap: u16,
+ buttons: u16,
+ left_trigger: u8,
+ right_trigger: u8,
+ left_stick_x: i16,
+ left_stick_y: i16,
+ right_stick_x: i16,
+ right_stick_y: i16,
+) -> OpenNowStreamerStatus {
+ ffi_status(|| {
+ let Some(handle) = (unsafe { handle.as_ref() }) else {
+ return OpenNowStreamerStatus::NullPointer;
+ };
+ if controller_id >= 4 {
+ return OpenNowStreamerStatus::InvalidConfig;
+ }
+ handle.input.submit(CapturedInput::Gamepad {
+ controller_id,
+ bitmap,
+ buttons,
+ left_trigger,
+ right_trigger,
+ left_stick_x,
+ left_stick_y,
+ right_stick_x,
+ right_stick_y,
+ });
+ OpenNowStreamerStatus::Ok
+ })
+}
+
+#[unsafe(no_mangle)]
+/// Submits one shell-local action to the embedded input queue.
+///
+/// # Safety
+///
+/// `handle` must be null or point to a live engine handle that is not being destroyed.
+pub unsafe extern "C" fn opennow_streamer_submit_local_action(
+ handle: *const OpenNowStreamer,
+ action: u32,
+) -> OpenNowStreamerStatus {
+ ffi_status(|| {
+ let Some(handle) = (unsafe { handle.as_ref() }) else {
+ return OpenNowStreamerStatus::NullPointer;
+ };
+ let action = match action {
+ OPENNOW_STREAMER_LOCAL_ACTION_GUIDE => EmbeddedLocalAction::Guide,
+ OPENNOW_STREAMER_LOCAL_ACTION_SCREENSHOT => EmbeddedLocalAction::Screenshot,
+ OPENNOW_STREAMER_LOCAL_ACTION_RECORDING_TOGGLE => EmbeddedLocalAction::RecordingToggle,
+ _ => return OpenNowStreamerStatus::InvalidConfig,
+ };
+ handle.input.submit_local_action(action);
+ OpenNowStreamerStatus::Ok
+ })
+}
+
+#[unsafe(no_mangle)]
+/// Changes native input capture state for the Qt window.
+///
+/// # Safety
+///
+/// `handle` must be null or point to a live engine handle that is not being destroyed.
+/// `raw_input_active` must be null or point to writable storage for one `bool`.
+pub unsafe extern "C" fn opennow_streamer_set_capture_active(
+ handle: *const OpenNowStreamer,
+ active: bool,
+ relative_mouse: bool,
+ window_handle: usize,
+ raw_input_active: *mut bool,
+) -> OpenNowStreamerStatus {
+ ffi_status(|| {
+ if handle.is_null() || raw_input_active.is_null() {
+ return OpenNowStreamerStatus::NullPointer;
+ }
+ let raw = unsafe { &*handle }
+ .input
+ .set_active(active, relative_mouse, window_handle);
+ unsafe {
+ raw_input_active.write(raw);
+ }
+ OpenNowStreamerStatus::Ok
+ })
+}
+
+#[unsafe(no_mangle)]
+/// Binds the caller's live graphics objects to the current scene-graph render thread.
+///
+/// Repeating the call with an identical context is idempotent. A changed context invalidates any
+/// queued or acquired frame from the previous context.
+///
+/// # Safety
+///
+/// `handle` must be live and must not race with destroy. `context` must point to a readable
+/// versioned context. Its native graphics objects must remain live until scene-graph shutdown,
+/// and this call must run on the thread that will acquire and record frames.
+pub unsafe extern "C" fn opennow_streamer_set_graphics_context(
+ handle: *const OpenNowStreamer,
+ context: *const OpenNowStreamerGraphicsContext,
+) -> OpenNowStreamerStatus {
+ match catch_unwind(AssertUnwindSafe(|| {
+ if handle.is_null() || context.is_null() {
+ return OpenNowStreamerStatus::NullPointer;
+ }
+ let version = unsafe { ptr::addr_of!((*context).version).read() };
+ let struct_size = unsafe { ptr::addr_of!((*context).struct_size).read() };
+ if version != OPENNOW_STREAMER_GRAPHICS_CONTEXT_VERSION
+ || struct_size < size_of::()
+ {
+ return OpenNowStreamerStatus::InvalidConfig;
+ }
+ let context = match graphics_context(unsafe { context.read() }) {
+ Ok(context) => context,
+ Err(status) => return status,
+ };
+ unsafe { &*handle }
+ .graphics
+ .initialize(context)
+ .map_or_else(graphics_status, |()| OpenNowStreamerStatus::Ok)
+ })) {
+ Ok(status) => status,
+ Err(_) => OpenNowStreamerStatus::Panic,
+ }
+}
+
+#[unsafe(no_mangle)]
+/// Acquires the newest pending GPU frame and transfers one retained reference to the caller.
+///
+/// # Safety
+///
+/// `handle` must be live. `output` and `info` must point to writable storage. The call must run on
+/// the bound render thread. A successful token must be released exactly once.
+pub unsafe extern "C" fn opennow_streamer_acquire_latest_frame(
+ handle: *const OpenNowStreamer,
+ output: *mut *mut OpenNowStreamerFrame,
+ info: *mut OpenNowStreamerFrameInfo,
+) -> OpenNowStreamerStatus {
+ match catch_unwind(AssertUnwindSafe(|| {
+ if handle.is_null() || output.is_null() || info.is_null() {
+ return OpenNowStreamerStatus::NullPointer;
+ }
+ unsafe {
+ output.write(ptr::null_mut());
+ info.write(OpenNowStreamerFrameInfo::default());
+ }
+ let token = match unsafe { &*handle }.graphics.acquire_latest() {
+ Ok(token) => token,
+ Err(error) => return graphics_status(error),
+ };
+ let frame_info = token.info();
+ unsafe {
+ info.write(OpenNowStreamerFrameInfo {
+ width: frame_info.width,
+ height: frame_info.height,
+ sequence: frame_info.sequence,
+ presentation_time_ns: frame_info.presentation_time_ns,
+ });
+ output.write(Box::into_raw(Box::new(OpenNowStreamerFrame { token })));
+ }
+ OpenNowStreamerStatus::Ok
+ })) {
+ Ok(status) => status,
+ Err(_) => OpenNowStreamerStatus::Panic,
+ }
+}
+
+#[unsafe(no_mangle)]
+/// Records the acquired frame into the caller's current GPU command stream.
+///
+/// # Safety
+///
+/// `handle` and `frame` must be live and belong to the same streamer. `command` must describe a
+/// writable command buffer owned by the bound graphics context. This call must happen before the
+/// QQuickRhiItem render pass begins. `output` receives textures that can then be sampled inside the
+/// item pass. All native objects must remain valid for the call, which must run on the bound render
+/// thread.
+pub unsafe extern "C" fn opennow_streamer_record_frame(
+ handle: *const OpenNowStreamer,
+ frame: *const OpenNowStreamerFrame,
+ command: *const OpenNowStreamerRecordCommand,
+ output: *mut OpenNowStreamerRecordedFrame,
+) -> OpenNowStreamerStatus {
+ match catch_unwind(AssertUnwindSafe(|| {
+ if handle.is_null() || frame.is_null() || command.is_null() || output.is_null() {
+ return OpenNowStreamerStatus::NullPointer;
+ }
+ unsafe {
+ output.write(OpenNowStreamerRecordedFrame::default());
+ }
+ let version = unsafe { ptr::addr_of!((*command).version).read() };
+ let struct_size = unsafe { ptr::addr_of!((*command).struct_size).read() };
+ if version != OPENNOW_STREAMER_RENDER_COMMAND_VERSION
+ || struct_size < size_of::()
+ {
+ return OpenNowStreamerStatus::InvalidConfig;
+ }
+ let command = match render_command(unsafe { command.read() }) {
+ Ok(command) => command,
+ Err(status) => return status,
+ };
+ let handle = unsafe { &*handle };
+ let recorded = match handle.graphics.record(&unsafe { &*frame }.token, command) {
+ Ok(recorded) => recorded,
+ Err(error) => return graphics_status(error),
+ };
+ let api = match handle.frame_publisher.context() {
+ Some(lease) => lease.context().api,
+ None => return OpenNowStreamerStatus::GraphicsUnavailable,
+ };
+ let recorded = match recorded_frame(api, recorded) {
+ Ok(recorded) => recorded,
+ Err(status) => return status,
+ };
+ unsafe {
+ output.write(recorded);
+ }
+ OpenNowStreamerStatus::Ok
+ })) {
+ Ok(status) => status,
+ Err(_) => OpenNowStreamerStatus::Panic,
+ }
+}
+
+#[unsafe(no_mangle)]
+/// Releases one acquired frame token.
+///
+/// # Safety
+///
+/// `frame` must be a live token returned by acquire and must be released exactly once. Release may
+/// run on any thread and remains valid after scene-graph shutdown.
+pub unsafe extern "C" fn opennow_streamer_release_frame(
+ frame: *mut OpenNowStreamerFrame,
+) -> OpenNowStreamerStatus {
+ match catch_unwind(AssertUnwindSafe(|| {
+ if frame.is_null() {
+ return OpenNowStreamerStatus::NullPointer;
+ }
+ drop(unsafe { Box::from_raw(frame) });
+ OpenNowStreamerStatus::Ok
+ })) {
+ Ok(status) => status,
+ Err(_) => OpenNowStreamerStatus::Panic,
+ }
+}
+
+#[unsafe(no_mangle)]
+/// Invalidates queued frames and releases all scene-graph-owned graphics state.
+///
+/// Acquired tokens remain releasable but cannot be recorded. The handle can later bind a new
+/// context, including from a replacement render thread.
+///
+/// # Safety
+///
+/// `handle` must be live and the call must run on the currently bound render thread. It must not
+/// race with another graphics operation or destroy.
+pub unsafe extern "C" fn opennow_streamer_scene_graph_shutdown(
+ handle: *const OpenNowStreamer,
+) -> OpenNowStreamerStatus {
+ match catch_unwind(AssertUnwindSafe(|| {
+ if handle.is_null() {
+ return OpenNowStreamerStatus::NullPointer;
+ }
+ unsafe { &*handle }
+ .graphics
+ .shutdown()
+ .map_or_else(graphics_status, |()| OpenNowStreamerStatus::Ok)
+ })) {
+ Ok(status) => status,
+ Err(_) => OpenNowStreamerStatus::Panic,
+ }
+}
+
+#[unsafe(no_mangle)]
+/// Copies and queues one serialized protocol command.
+///
+/// # Safety
+///
+/// `handle` must be a live handle returned by create and must not race with destroy. When `length`
+/// is nonzero, `bytes` must identify a readable allocation of at least `length` bytes.
+pub unsafe extern "C" fn opennow_streamer_send(
+ handle: *const OpenNowStreamer,
+ bytes: *const u8,
+ length: usize,
+) -> OpenNowStreamerStatus {
+ match catch_unwind(AssertUnwindSafe(|| {
+ if handle.is_null() || (bytes.is_null() && length != 0) {
+ return OpenNowStreamerStatus::NullPointer;
+ }
+ let handle = unsafe { &*handle };
+ let bytes = if length == 0 {
+ &[]
+ } else {
+ unsafe { std::slice::from_raw_parts(bytes, length) }
+ };
+ handle.send(bytes)
+ })) {
+ Ok(status) => status,
+ Err(_) => OpenNowStreamerStatus::Panic,
+ }
+}
+
+#[unsafe(no_mangle)]
+/// Stops the engine, drains callbacks, and consumes the handle.
+///
+/// # Safety
+///
+/// `handle` must be a live handle returned by create. It must be passed to destroy exactly once,
+/// with no concurrent or subsequent access through the pointer.
+pub unsafe extern "C" fn opennow_streamer_destroy(
+ handle: *mut OpenNowStreamer,
+) -> OpenNowStreamerStatus {
+ match catch_unwind(AssertUnwindSafe(|| {
+ if handle.is_null() {
+ return OpenNowStreamerStatus::NullPointer;
+ }
+ if unsafe { &*handle }.graphics.is_active() {
+ return OpenNowStreamerStatus::SceneGraphActive;
+ }
+ let mut handle = unsafe { Box::from_raw(handle) };
+ handle.shutdown();
+ OpenNowStreamerStatus::Ok
+ })) {
+ Ok(status) => status,
+ Err(_) => OpenNowStreamerStatus::Panic,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::sync::{Condvar, Mutex};
+ use std::time::{Duration, Instant};
+
+ use opennow_streamer_platform::{GraphicsFrame, GraphicsFrameInfo, create_test_runtime};
+ use serde_json::json;
+
+ use super::*;
+
+ #[derive(Default)]
+ struct CallbackMessages {
+ values: Mutex>,
+ changed: Condvar,
+ frames_available: AtomicUsize,
+ }
+
+ impl CallbackMessages {
+ fn wait_for_id(&self, id: &str) -> Value {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ let mut values = self.values.lock().expect("callback values");
+ loop {
+ if let Some(value) = values.iter().find(|value| value["id"] == id) {
+ return value.clone();
+ }
+ let timeout = deadline.saturating_duration_since(Instant::now());
+ assert!(!timeout.is_zero(), "timed out waiting for response {id}");
+ let (next, result) = self
+ .changed
+ .wait_timeout(values, timeout)
+ .expect("callback wait");
+ values = next;
+ assert!(!result.timed_out(), "timed out waiting for response {id}");
+ }
+ }
+ }
+
+ unsafe extern "C" fn collect_response(bytes: *const u8, length: usize, user_data: *mut c_void) {
+ let bytes = unsafe { std::slice::from_raw_parts(bytes, length) };
+ let Ok(value) = serde_json::from_slice(bytes) else {
+ return;
+ };
+ let messages = unsafe { &*(user_data as *const CallbackMessages) };
+ messages.values.lock().expect("callback values").push(value);
+ messages.changed.notify_all();
+ }
+
+ unsafe extern "C" fn collect_frame_available(user_data: *mut c_void) {
+ let messages = unsafe { &*(user_data as *const CallbackMessages) };
+ messages.frames_available.fetch_add(1, Ordering::Relaxed);
+ }
+
+ fn test_config(messages: &CallbackMessages) -> OpenNowStreamerConfig {
+ OpenNowStreamerConfig {
+ abi_version: OPENNOW_STREAMER_FFI_ABI_VERSION,
+ struct_size: size_of::(),
+ command_queue_capacity: 4,
+ response_queue_capacity: 4,
+ event_queue_capacity: 4,
+ max_command_bytes: 4096,
+ response_callback: Some(collect_response),
+ event_callback: None,
+ frame_available_callback: Some(collect_frame_available),
+ cursor_callback: None,
+ user_data: ptr::from_ref(messages).cast_mut().cast(),
+ }
+ }
+
+ fn create_with_test_runtime(
+ messages: &CallbackMessages,
+ ) -> (
+ OpenNowStreamer,
+ opennow_streamer_platform::TestMediaRuntimeHost,
+ ) {
+ let (host, runtime) = create_test_runtime();
+ let shutdown_runtime = runtime.clone();
+ let handle = OpenNowStreamer::create(
+ test_config(messages),
+ runtime.captured_input(),
+ move |events, _frames, _cursor| Engine::with_embedded_media_runtime(events, runtime),
+ move || shutdown_runtime.shutdown(),
+ )
+ .expect("FFI handle");
+ (handle, host)
+ }
+
+ #[derive(Default)]
+ struct RecordedCommands {
+ values: Mutex>,
+ drops: AtomicUsize,
+ }
+
+ struct TestGraphicsFrame {
+ sequence: u64,
+ recorded: Arc,
+ panic_on_record: bool,
+ }
+
+ impl GraphicsFrame for TestGraphicsFrame {
+ fn info(&self) -> GraphicsFrameInfo {
+ GraphicsFrameInfo {
+ width: 2560,
+ height: 1440,
+ sequence: self.sequence,
+ presentation_time_ns: 8_333_333 * self.sequence,
+ }
+ }
+
+ fn record(
+ &self,
+ context: GraphicsContext,
+ command: GraphicsRecordCommand,
+ ) -> Result {
+ assert!(!self.panic_on_record, "injected render panic");
+ self.recorded
+ .values
+ .lock()
+ .expect("recorded commands")
+ .push((context, command));
+ Ok(GraphicsRecordedFrame {
+ resource: 0xfeed,
+ resource_view: 0xbeef,
+ width: 2560,
+ height: 1440,
+ frame_slot: command.frame_slot,
+ generation: self.sequence,
+ presentation_time_ns: 8_333_333 * self.sequence,
+ })
+ }
+ }
+
+ impl Drop for TestGraphicsFrame {
+ fn drop(&mut self) {
+ self.recorded.drops.fetch_add(1, Ordering::Relaxed);
+ }
+ }
+
+ fn ffi_graphics_context() -> OpenNowStreamerGraphicsContext {
+ OpenNowStreamerGraphicsContext {
+ version: OPENNOW_STREAMER_GRAPHICS_CONTEXT_VERSION,
+ struct_size: size_of::(),
+ graphics_api: OPENNOW_STREAMER_GRAPHICS_API_VULKAN,
+ instance: ptr::dangling_mut(),
+ physical_device: ptr::dangling_mut(),
+ device: ptr::dangling_mut(),
+ queue: ptr::dangling_mut(),
+ queue_family_index: 5,
+ }
+ }
+
+ fn ffi_render_command() -> OpenNowStreamerRecordCommand {
+ OpenNowStreamerRecordCommand {
+ version: OPENNOW_STREAMER_RENDER_COMMAND_VERSION,
+ struct_size: size_of::(),
+ command_buffer: ptr::dangling_mut(),
+ frame_slot: 2,
+ }
+ }
+
+ fn graphics_test_handle(messages: &CallbackMessages) -> OpenNowStreamer {
+ OpenNowStreamer::create(
+ test_config(messages),
+ Arc::new(CapturedInputQueue::default()),
+ |events, _frames, _cursor| Engine::embedded(events),
+ || {},
+ )
+ .expect("FFI handle")
+ }
+
+ #[test]
+ fn typed_submit_api_routes_only_captured_events_to_the_rust_queue() {
+ let messages = Box::new(CallbackMessages::default());
+ let mut handle = graphics_test_handle(&messages);
+ handle.input.set_active(true, false, 0);
+
+ assert_eq!(
+ unsafe { opennow_streamer_submit_key(&handle, 0x57, 0x02, true) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_submit_mouse_relative(&handle, -12, 34) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_submit_mouse_absolute(&handle, 20, 30, 640, 360) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_submit_mouse_button(&handle, 5, true) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_submit_mouse_wheel(&handle, 0, -120) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe {
+ opennow_streamer_submit_gamepad(&handle, 2, 0x0404, 0x1001, 4, 5, -6, 7, -8, 9)
+ },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe {
+ opennow_streamer_submit_local_action(&handle, OPENNOW_STREAMER_LOCAL_ACTION_GUIDE)
+ },
+ OpenNowStreamerStatus::Ok
+ );
+
+ let queue = handle.input.queue();
+ assert_eq!(
+ queue.take(),
+ Some(CapturedInput::Key {
+ virtual_key: 0x57,
+ modifiers: 0x02,
+ pressed: true,
+ })
+ );
+ assert_eq!(
+ queue.take(),
+ Some(CapturedInput::MouseMove {
+ delta_x: -12,
+ delta_y: 34,
+ })
+ );
+ assert_eq!(
+ queue.take(),
+ Some(CapturedInput::MouseAbsolute {
+ x: 20,
+ y: 30,
+ width: 640,
+ height: 360,
+ })
+ );
+ assert_eq!(
+ queue.take(),
+ Some(CapturedInput::MouseButton {
+ button: 5,
+ pressed: true,
+ })
+ );
+ assert_eq!(
+ queue.take(),
+ Some(CapturedInput::MouseWheel {
+ delta_x: 0,
+ delta_y: -120,
+ })
+ );
+ assert_eq!(
+ queue.take(),
+ Some(CapturedInput::Gamepad {
+ controller_id: 2,
+ bitmap: 0x0404,
+ buttons: 0x1001,
+ left_trigger: 4,
+ right_trigger: 5,
+ left_stick_x: -6,
+ left_stick_y: 7,
+ right_stick_x: -8,
+ right_stick_y: 9,
+ })
+ );
+ assert_eq!(queue.take(), Some(CapturedInput::Guide));
+ assert_eq!(queue.take(), None);
+
+ handle.shutdown();
+ }
+
+ #[test]
+ fn typed_submit_api_validates_boundaries_without_crossing_the_ffi() {
+ let messages = Box::new(CallbackMessages::default());
+ let mut handle = graphics_test_handle(&messages);
+ assert_eq!(
+ unsafe { opennow_streamer_submit_mouse_absolute(&handle, 0, 0, 0, 1) },
+ OpenNowStreamerStatus::InvalidConfig
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_submit_mouse_button(&handle, 6, true) },
+ OpenNowStreamerStatus::InvalidConfig
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_submit_gamepad(&handle, 4, 0, 0, 0, 0, 0, 0, 0, 0) },
+ OpenNowStreamerStatus::InvalidConfig
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_submit_local_action(&handle, u32::MAX) },
+ OpenNowStreamerStatus::InvalidConfig
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_submit_key(ptr::null(), 0, 0, false) },
+ OpenNowStreamerStatus::NullPointer
+ );
+ handle.shutdown();
+ }
+
+ #[test]
+ fn graphics_ffi_acquires_records_and_releases_the_latest_frame() {
+ let messages = Box::new(CallbackMessages::default());
+ let mut handle = graphics_test_handle(&messages);
+ let context = ffi_graphics_context();
+ assert_eq!(
+ unsafe { opennow_streamer_set_graphics_context(&handle, &context) },
+ OpenNowStreamerStatus::Ok
+ );
+ let publisher = handle.frame_publisher();
+ let lease = publisher.context().expect("graphics context lease");
+ let recorded = Arc::new(RecordedCommands::default());
+ publisher
+ .publish(
+ lease,
+ Arc::new(TestGraphicsFrame {
+ sequence: 42,
+ recorded: Arc::clone(&recorded),
+ panic_on_record: false,
+ }),
+ )
+ .expect("publish frame");
+ assert_eq!(messages.frames_available.load(Ordering::Relaxed), 1);
+
+ let mut token = ptr::null_mut();
+ let mut info = OpenNowStreamerFrameInfo::default();
+ assert_eq!(
+ unsafe { opennow_streamer_acquire_latest_frame(&handle, &mut token, &mut info) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert!(!token.is_null());
+ assert_eq!(
+ info,
+ OpenNowStreamerFrameInfo {
+ width: 2560,
+ height: 1440,
+ sequence: 42,
+ presentation_time_ns: 349_999_986,
+ }
+ );
+ let command = ffi_render_command();
+ let mut output = OpenNowStreamerRecordedFrame::default();
+ assert_eq!(
+ unsafe { opennow_streamer_record_frame(&handle, token, &command, &mut output) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(output.graphics_api, OPENNOW_STREAMER_GRAPHICS_API_VULKAN);
+ assert_eq!(output.width, 2560);
+ assert_eq!(output.height, 1440);
+ assert_eq!(output.frame_slot, 2);
+ assert_eq!(output.generation, 42);
+ assert_eq!(output.resource, 0xfeed);
+ assert_eq!(output.resource_view, 0xbeef);
+ assert_eq!(output.presentation_time_ns, 349_999_986);
+ assert_eq!(
+ unsafe { opennow_streamer_record_frame(&handle, token, &command, &mut output) },
+ OpenNowStreamerStatus::FrameAlreadyRecorded
+ );
+ assert_eq!(recorded.drops.load(Ordering::Relaxed), 0);
+ assert_eq!(
+ unsafe { opennow_streamer_release_frame(token) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(recorded.drops.load(Ordering::Relaxed), 1);
+
+ let values = recorded.values.lock().expect("recorded commands");
+ assert_eq!(values.len(), 1);
+ assert_eq!(values[0].0.api, GraphicsApi::Vulkan);
+ assert_eq!(values[0].0.device, context.device as usize);
+ assert_eq!(values[0].0.queue, context.queue as usize);
+ assert_eq!(values[0].1.command_buffer, command.command_buffer as usize);
+ assert_eq!(values[0].1.frame_slot, 2);
+ drop(values);
+
+ assert_eq!(
+ unsafe { opennow_streamer_scene_graph_shutdown(&handle) },
+ OpenNowStreamerStatus::Ok
+ );
+ handle.shutdown();
+ }
+
+ #[test]
+ fn scene_graph_shutdown_invalidates_tokens_without_leaking_them() {
+ let messages = Box::new(CallbackMessages::default());
+ let mut handle = graphics_test_handle(&messages);
+ let context = ffi_graphics_context();
+ assert_eq!(
+ unsafe { opennow_streamer_set_graphics_context(&handle, &context) },
+ OpenNowStreamerStatus::Ok
+ );
+ let publisher = handle.frame_publisher();
+ let recorded = Arc::new(RecordedCommands::default());
+ publisher
+ .publish(
+ publisher.context().expect("graphics context lease"),
+ Arc::new(TestGraphicsFrame {
+ sequence: 1,
+ recorded: Arc::clone(&recorded),
+ panic_on_record: false,
+ }),
+ )
+ .expect("publish frame");
+ let mut token = ptr::null_mut();
+ let mut info = OpenNowStreamerFrameInfo::default();
+ assert_eq!(
+ unsafe { opennow_streamer_acquire_latest_frame(&handle, &mut token, &mut info) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_scene_graph_shutdown(&handle) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe {
+ opennow_streamer_record_frame(
+ &handle,
+ token,
+ &ffi_render_command(),
+ &mut OpenNowStreamerRecordedFrame::default(),
+ )
+ },
+ OpenNowStreamerStatus::GraphicsUnavailable
+ );
+ assert_eq!(recorded.drops.load(Ordering::Relaxed), 0);
+ assert_eq!(
+ unsafe { opennow_streamer_release_frame(token) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(recorded.drops.load(Ordering::Relaxed), 1);
+ handle.shutdown();
+ }
+
+ #[test]
+ fn graphics_ffi_contains_panics_from_frame_recording() {
+ let messages = Box::new(CallbackMessages::default());
+ let mut handle = graphics_test_handle(&messages);
+ assert_eq!(
+ unsafe { opennow_streamer_set_graphics_context(&handle, &ffi_graphics_context()) },
+ OpenNowStreamerStatus::Ok
+ );
+ let publisher = handle.frame_publisher();
+ let recorded = Arc::new(RecordedCommands::default());
+ publisher
+ .publish(
+ publisher.context().expect("graphics context lease"),
+ Arc::new(TestGraphicsFrame {
+ sequence: 1,
+ recorded,
+ panic_on_record: true,
+ }),
+ )
+ .expect("publish frame");
+ let mut token = ptr::null_mut();
+ let mut info = OpenNowStreamerFrameInfo::default();
+ assert_eq!(
+ unsafe { opennow_streamer_acquire_latest_frame(&handle, &mut token, &mut info) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe {
+ opennow_streamer_record_frame(
+ &handle,
+ token,
+ &ffi_render_command(),
+ &mut OpenNowStreamerRecordedFrame::default(),
+ )
+ },
+ OpenNowStreamerStatus::Panic
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_release_frame(token) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_scene_graph_shutdown(&handle) },
+ OpenNowStreamerStatus::Ok
+ );
+ handle.shutdown();
+ }
+
+ #[test]
+ fn destroy_rejects_an_active_scene_graph_without_consuming_the_handle() {
+ let messages = Box::new(CallbackMessages::default());
+ let handle = Box::into_raw(Box::new(graphics_test_handle(&messages)));
+ assert_eq!(
+ unsafe { opennow_streamer_set_graphics_context(handle, &ffi_graphics_context()) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_destroy(handle) },
+ OpenNowStreamerStatus::SceneGraphActive
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_scene_graph_shutdown(handle) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert_eq!(
+ unsafe { opennow_streamer_destroy(handle) },
+ OpenNowStreamerStatus::Ok
+ );
+ }
+
+ #[test]
+ fn hello_round_trips_through_the_response_callback_with_test_media_runtime() {
+ let messages = Box::new(CallbackMessages::default());
+ let (mut handle, host) = create_with_test_runtime(&messages);
+ let command = serde_json::to_vec(&json!({
+ "id": "hello-1",
+ "type": "hello",
+ "protocolVersion": 5
+ }))
+ .expect("hello command");
+
+ assert_eq!(handle.send(&command), OpenNowStreamerStatus::Ok);
+ let response = messages.wait_for_id("hello-1");
+ assert_eq!(response["type"], "ready");
+ assert!(
+ response["capabilities"]
+ .get("supportsOfferAnswer")
+ .is_none()
+ );
+ assert!(response["capabilities"].get("microphoneDevices").is_none());
+
+ handle.shutdown();
+ host.join().expect("test media runtime");
+ }
+
+ #[test]
+ fn public_constructor_installs_the_production_in_process_media_runtime() {
+ let messages = Box::new(CallbackMessages::default());
+ let config = test_config(&messages);
+ let mut handle = ptr::null_mut();
+ assert_eq!(
+ unsafe { opennow_streamer_create(&config, &mut handle) },
+ OpenNowStreamerStatus::Ok
+ );
+ assert!(!handle.is_null());
+ let command = serde_json::to_vec(&json!({
+ "id": "hello-production",
+ "type": "hello",
+ "protocolVersion": 5
+ }))
+ .expect("hello command");
+ assert_eq!(
+ unsafe { &*handle }.send(&command),
+ OpenNowStreamerStatus::Ok
+ );
+ let response = messages.wait_for_id("hello-production");
+ assert!(
+ response["capabilities"]
+ .get("supportsOfferAnswer")
+ .is_none()
+ );
+ assert_eq!(response["capabilities"]["supportsAudioDecode"], true);
+ assert_eq!(response["capabilities"]["supportsAudioOutput"], true);
+ assert_eq!(
+ unsafe { opennow_streamer_destroy(handle) },
+ OpenNowStreamerStatus::Ok
+ );
+ }
+
+ #[test]
+ fn shutdown_responds_and_closes_the_command_queue() {
+ let messages = Box::new(CallbackMessages::default());
+ let (mut handle, host) = create_with_test_runtime(&messages);
+ let command = br#"{"id":"shutdown-1","type":"shutdown","reason":"ffi test"}"#;
+
+ assert_eq!(handle.send(command), OpenNowStreamerStatus::Ok);
+ let response = messages.wait_for_id("shutdown-1");
+ assert_eq!(response["type"], "ok");
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while handle.send(br#"{"id":"late","type":"hello"}"#) != OpenNowStreamerStatus::Closed {
+ assert!(Instant::now() < deadline, "command queue did not close");
+ thread::yield_now();
+ }
+
+ handle.shutdown();
+ host.join().expect("test media runtime");
+ }
+}
diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/format.rs b/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/format.rs
index dbee142a2..db5b6aed1 100644
--- a/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/format.rs
+++ b/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/format.rs
@@ -220,6 +220,7 @@ pub struct VulkanVideoFrame {
device_context: usize,
lock_queue: Option,
unlock_queue: Option,
+ cpu_nv12_fallback: Option Result> + Send + Sync>>,
owner: Arc,
}
@@ -249,10 +250,28 @@ impl VulkanVideoFrame {
device_context,
lock_queue,
unlock_queue,
+ cpu_nv12_fallback: None,
owner,
}
}
+ pub fn with_cpu_nv12_fallback(
+ mut self,
+ fallback: Arc Result> + Send + Sync>,
+ ) -> Self {
+ self.cpu_nv12_fallback = Some(fallback);
+ self
+ }
+
+ pub fn download_nv12(&self) -> Result> {
+ self.cpu_nv12_fallback.as_ref().ok_or_else(|| {
+ Error::unavailable(
+ crate::Subsystem::Vulkan,
+ "Vulkan frame has no CPU NV12 fallback",
+ )
+ })?()
+ }
+
pub fn validate(&self) -> Result<()> {
if self.instance == 0
|| self.physical_device == 0
@@ -478,4 +497,51 @@ mod tests {
};
assert!(frame.validate().is_err());
}
+
+ #[test]
+ fn vulkan_frame_cpu_fallback_is_lazy_and_typed_as_nv12_planes() {
+ let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+ let fallback_calls = Arc::clone(&calls);
+ let frame = VulkanVideoFrame::new(
+ 1,
+ 2,
+ 3,
+ vec![0],
+ 0,
+ 0,
+ vec![VulkanImage {
+ image: 4,
+ format: 1,
+ width: 4,
+ height: 4,
+ layout: 1,
+ access: 0,
+ semaphore: 5,
+ semaphore_value: 1,
+ queue_family: 0,
+ }],
+ 0,
+ None,
+ None,
+ Arc::new(()),
+ )
+ .with_cpu_nv12_fallback(Arc::new(move || {
+ fallback_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ Ok(vec![
+ FramePlane {
+ data: Arc::from(vec![0_u8; 16]),
+ stride: 4,
+ rows: 4,
+ },
+ FramePlane {
+ data: Arc::from(vec![0_u8; 8]),
+ stride: 4,
+ rows: 2,
+ },
+ ])
+ }));
+ assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
+ assert_eq!(frame.download_nv12().unwrap().len(), 2);
+ assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
+ }
}
diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/frame_producer.rs b/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/frame_producer.rs
new file mode 100644
index 000000000..c66177af1
--- /dev/null
+++ b/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/frame_producer.rs
@@ -0,0 +1,1871 @@
+use std::collections::{HashMap, hash_map::Entry as HashMapEntry};
+use std::io::Cursor;
+use std::os::fd::RawFd;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, Weak};
+use std::time::Duration;
+
+use ash::vk::{self, Handle};
+
+use crate::{
+ DecodedVideoFrame, DmaBufFrame, DmaBufPlane, Error, FramePlane, PixelFormat, Result, Subsystem,
+ VulkanVideoFrame,
+};
+
+const DRM_FORMAT_NV12: u32 = u32::from_le_bytes(*b"NV12");
+const DRM_FORMAT_R8: u32 = u32::from_le_bytes(*b"R8 ");
+const DRM_FORMAT_GR88: u32 = u32::from_le_bytes(*b"GR88");
+const DECODE_WAIT_TIMEOUT: Duration = Duration::from_secs(1);
+const NV12_VERTEX_SHADER: &[u8] = include_bytes!("../shaders/nv12.vert.spv");
+const NV12_FRAGMENT_SHADER: &[u8] = include_bytes!("../shaders/nv12.frag.spv");
+const DEFAULT_FRAME_SLOTS: u32 = 3;
+const MAX_FRAME_SLOTS: u32 = 8;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct VulkanRenderDevice {
+ pub instance: usize,
+ pub physical_device: usize,
+ pub device: usize,
+ pub queue_family: u32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RecordedGpuFrame {
+ pub slot: u32,
+ pub generation: u64,
+ pub image: u64,
+ pub image_view: u64,
+ pub width: u32,
+ pub height: u32,
+ pub timestamp_us: u64,
+}
+
+#[derive(Clone)]
+pub struct LinuxGpuFrameProducer {
+ state: Arc>,
+ sequence: Arc,
+ slot_count: u32,
+}
+
+struct SharedProducerState {
+ render: Option,
+ producer: Option,
+}
+
+pub struct LinuxGpuFrame {
+ frame: DecodedVideoFrame,
+ producer: LinuxGpuFrameProducer,
+ sequence: u64,
+}
+
+impl LinuxGpuFrameProducer {
+ pub fn new(slot_count: u32) -> Result {
+ if slot_count == 0 || slot_count > MAX_FRAME_SLOTS {
+ return Err(Error::InvalidFormat(
+ "embedded Vulkan frame slot count must be between 1 and 8".to_owned(),
+ ));
+ }
+ Ok(Self {
+ state: Arc::new(Mutex::new(SharedProducerState {
+ render: None,
+ producer: None,
+ })),
+ sequence: Arc::new(AtomicU64::new(0)),
+ slot_count,
+ })
+ }
+
+ pub fn frame(&self, frame: DecodedVideoFrame) -> Result {
+ frame.validate()?;
+ Ok(LinuxGpuFrame {
+ frame,
+ producer: self.clone(),
+ sequence: self.sequence.fetch_add(1, Ordering::Relaxed) + 1,
+ })
+ }
+}
+
+impl LinuxGpuFrame {
+ pub fn width(&self) -> u32 {
+ self.frame.format.width
+ }
+
+ pub fn height(&self) -> u32 {
+ self.frame.format.height
+ }
+
+ pub fn sequence(&self) -> u64 {
+ self.sequence
+ }
+
+ pub fn presentation_time_ns(&self) -> u64 {
+ self.frame.timestamp_us.saturating_mul(1_000)
+ }
+
+ /// Records conversion before Qt begins its item render pass.
+ ///
+ /// # Safety
+ ///
+ /// `render` and `command_buffer` have the requirements documented by
+ /// [`LinuxFrameProducer::new_with_slots`] and
+ /// [`LinuxFrameProducer::record_frame`].
+ pub unsafe fn record(
+ &self,
+ render: VulkanRenderDevice,
+ command_buffer: usize,
+ frame_slot: u32,
+ ) -> Result {
+ let mut state = self
+ .producer
+ .state
+ .lock()
+ .unwrap_or_else(|poison| poison.into_inner());
+ if state.render != Some(render) {
+ state.producer = None;
+ state.producer = Some(unsafe {
+ LinuxFrameProducer::new_with_slots(render, self.producer.slot_count)?
+ });
+ state.render = Some(render);
+ }
+ unsafe {
+ state
+ .producer
+ .as_mut()
+ .expect("producer initialized")
+ .record_frame(self.frame.clone(), command_buffer, frame_slot)
+ }
+ }
+}
+
+impl VulkanRenderDevice {
+ fn validate(self) -> Result<()> {
+ if self.instance == 0 || self.physical_device == 0 || self.device == 0 {
+ return Err(Error::InvalidFormat(
+ "Qt Vulkan render device contains a null handle".to_owned(),
+ ));
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PreparedVulkanImage {
+ pub image: u64,
+ pub format: i32,
+ pub width: u32,
+ pub height: u32,
+ pub old_layout: i32,
+ pub old_access: u64,
+ pub source_queue_family: u32,
+ pub render_queue_family: u32,
+}
+
+#[derive(Debug)]
+pub struct PreparedVulkanFrame {
+ pub images: Vec,
+ source: Arc,
+}
+
+impl PreparedVulkanFrame {
+ pub fn retain_source(&self) -> &Arc {
+ &self.source
+ }
+}
+
+#[derive(Debug)]
+pub struct CpuNv12Frame {
+ pub luma: FramePlane,
+ pub chroma: FramePlane,
+ source: Arc,
+}
+
+impl CpuNv12Frame {
+ pub fn retain_source(&self) -> &Arc {
+ &self.source
+ }
+}
+
+#[derive(Debug)]
+pub enum PreparedLinuxFrame {
+ Vulkan(PreparedVulkanFrame),
+ DmaBuf(Arc),
+ Cpu(CpuNv12Frame),
+}
+
+impl PreparedLinuxFrame {
+ pub fn timestamp_us(&self) -> u64 {
+ match self {
+ Self::Vulkan(frame) => frame.source.timestamp_us,
+ Self::DmaBuf(frame) => frame.source.timestamp_us,
+ Self::Cpu(frame) => frame.source.timestamp_us,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+struct DmaBufKey {
+ device: u64,
+ inode: u64,
+ width: u32,
+ height: u32,
+ modifier: u64,
+ luma_offset: usize,
+ luma_pitch: usize,
+ chroma_offset: usize,
+ chroma_pitch: usize,
+}
+
+pub struct ImportedNv12Frame {
+ pub image: u64,
+ pub luma_view: u64,
+ pub chroma_view: u64,
+ pub modifier: u64,
+ pub external_queue_family: u32,
+ pub render_queue_family: u32,
+ image_handle: vk::Image,
+ luma_view_handle: vk::ImageView,
+ chroma_view_handle: vk::ImageView,
+ memory: vk::DeviceMemory,
+ device: ash::Device,
+ source: Arc,
+}
+
+impl ImportedNv12Frame {
+ pub fn retain_source(&self) -> &Arc {
+ &self.source
+ }
+}
+
+impl std::fmt::Debug for ImportedNv12Frame {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter
+ .debug_struct("ImportedNv12Frame")
+ .field("image", &self.image)
+ .field("luma_view", &self.luma_view)
+ .field("chroma_view", &self.chroma_view)
+ .field("modifier", &self.modifier)
+ .finish_non_exhaustive()
+ }
+}
+
+impl Drop for ImportedNv12Frame {
+ fn drop(&mut self) {
+ unsafe {
+ self.device
+ .destroy_image_view(self.chroma_view_handle, None);
+ self.device.destroy_image_view(self.luma_view_handle, None);
+ self.device.destroy_image(self.image_handle, None);
+ self.device.free_memory(self.memory, None);
+ }
+ }
+}
+
+pub struct LinuxFrameProducer {
+ _entry: ash::Entry,
+ instance: ash::Instance,
+ physical_device: vk::PhysicalDevice,
+ device: ash::Device,
+ render: VulkanRenderDevice,
+ dmabuf_import_supported: bool,
+ imported: HashMap>,
+ renderer: Option,
+ slots: Vec