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 GFN · this machine
@@ -257,7 +257,7 @@

Decode and present

Audio

Read the long form in audio.md.

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>, + generation: u64, +} + +struct RendererResources { + render_pass: vk::RenderPass, + descriptor_set_layout: vk::DescriptorSetLayout, + pipeline_layout: vk::PipelineLayout, + pipeline: vk::Pipeline, + descriptor_pool: vk::DescriptorPool, + descriptor_sets: Vec, + sampler: vk::Sampler, +} + +struct GpuImage { + image: vk::Image, + memory: vk::DeviceMemory, + view: vk::ImageView, +} + +struct CpuUploadResources { + luma: GpuImage, + chroma: GpuImage, + staging_buffer: vk::Buffer, + staging_memory: vk::DeviceMemory, + initialized: bool, +} + +struct FrameSlotResources { + width: u32, + height: u32, + output: GpuImage, + output_initialized: bool, + framebuffer: vk::Framebuffer, + cpu: Option, + owned_input_views: Vec, + frame: Option, +} + +impl LinuxFrameProducer { + /// Adopts Qt's Vulkan handles without taking ownership of them. + /// + /// The producer and every prepared frame must be dropped before Qt destroys + /// the corresponding QRhi. Calls must remain on the QRhi render thread. + /// + /// # Safety + /// + /// All handles must belong to the same live Vulkan device and remain valid + /// for the lifetime described above. + pub unsafe fn new(render: VulkanRenderDevice) -> Result { + unsafe { Self::new_with_slots(render, DEFAULT_FRAME_SLOTS) } + } + + /// # Safety + /// + /// This has the same requirements as [`Self::new`]. `slot_count` must be + /// Qt's maximum number of in-flight QRhi frame slots. + pub unsafe fn new_with_slots(render: VulkanRenderDevice, slot_count: u32) -> Result { + render.validate()?; + 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(), + )); + } + let entry = unsafe { ash::Entry::load() }.map_err(|error| { + Error::unavailable(Subsystem::Vulkan, format!("load Vulkan entry: {error}")) + })?; + let instance = unsafe { + ash::Instance::load( + entry.static_fn(), + vk::Instance::from_raw(render.instance as u64), + ) + }; + let physical_device = vk::PhysicalDevice::from_raw(render.physical_device as u64); + let device = unsafe { + ash::Device::load( + instance.fp_v1_0(), + vk::Device::from_raw(render.device as u64), + ) + }; + let required = [ + ash::khr::external_memory::NAME, + ash::khr::external_memory_fd::NAME, + ash::ext::external_memory_dma_buf::NAME, + ash::ext::image_drm_format_modifier::NAME, + ]; + let available = unsafe { instance.enumerate_device_extension_properties(physical_device) } + .map_err(|error| vk_error("enumerate Qt Vulkan device extensions", error))?; + let dmabuf_import_supported = required.iter().all(|required| { + available.iter().any(|extension| { + let name = unsafe { std::ffi::CStr::from_ptr(extension.extension_name.as_ptr()) }; + name == *required + }) + }); + Ok(Self { + _entry: entry, + instance, + physical_device, + device, + render, + dmabuf_import_supported, + imported: HashMap::new(), + renderer: None, + slots: (0..slot_count).map(|_| None).collect(), + generation: 0, + }) + } + + /// Encodes NV12-to-RGBA into Qt's current Vulkan command buffer. + /// + /// The command buffer must be recording but outside a render pass. This + /// method never begins, ends, submits, or waits for Qt's command buffer. + /// Qt may reuse a slot only after its previous frame has completed. + /// + /// # Safety + /// + /// `command_buffer` must belong to the adopted device and be in the + /// recording state on the QRhi render thread. + pub unsafe fn record_frame( + &mut self, + frame: DecodedVideoFrame, + command_buffer: usize, + slot: u32, + ) -> Result { + if command_buffer == 0 { + return Err(Error::InvalidFormat( + "Qt supplied a null Vulkan command buffer".to_owned(), + )); + } + let slot_index = usize::try_from(slot) + .ok() + .filter(|index| *index < self.slots.len()) + .ok_or_else(|| Error::InvalidFormat(format!("invalid QRhi frame slot {slot}")))?; + let width = frame.format.width; + let height = frame.format.height; + let timestamp_us = frame.timestamp_us; + let color_matrix = frame.format.color_matrix; + let full_range = frame.format.color_range == crate::ColorRange::Full; + let prepared = self.prepare(frame)?; + self.ensure_renderer()?; + self.ensure_slot(slot_index, width, height)?; + let command_buffer = vk::CommandBuffer::from_raw(command_buffer as u64); + let (luma_view, chroma_view) = + self.prepare_input(slot_index, command_buffer, &prepared, width, height)?; + self.update_descriptors(slot_index, luma_view, chroma_view); + unsafe { + self.record_conversion( + slot_index, + command_buffer, + &prepared, + width, + height, + color_matrix, + full_range, + )?; + } + self.generation = self.generation.wrapping_add(1); + let generation = self.generation; + let slot_resources = self.slots[slot_index] + .as_mut() + .expect("frame slot initialized"); + slot_resources.frame = Some(prepared); + Ok(RecordedGpuFrame { + slot, + generation, + image: slot_resources.output.image.as_raw(), + image_view: slot_resources.output.view.as_raw(), + width, + height, + timestamp_us, + }) + } + + pub fn prepare(&mut self, frame: DecodedVideoFrame) -> Result { + frame.validate()?; + if frame.format.pixel_format != PixelFormat::Nv12 { + return Err(Error::InvalidFormat(format!( + "embedded Linux presentation requires NV12, received {:?}", + frame.format.pixel_format + ))); + } + let frame = Arc::new(frame); + let mut decode_waited = frame.vulkan.is_none(); + if let Some(vulkan) = frame.vulkan.as_ref() { + decode_waited = self.wait_for_decode(vulkan).is_ok(); + if vulkan.device == self.render.device && decode_waited { + let images = vulkan + .images + .iter() + .map(|image| PreparedVulkanImage { + image: image.image, + format: image.format, + width: image.width, + height: image.height, + old_layout: image.layout, + old_access: image.access, + source_queue_family: image.queue_family, + render_queue_family: self.render.queue_family, + }) + .collect(); + return Ok(PreparedLinuxFrame::Vulkan(PreparedVulkanFrame { + images, + source: frame, + })); + } + } + let mut gpu_error = None; + if decode_waited && self.dmabuf_import_supported && frame.dmabuf.is_some() { + match self.import_dmabuf(Arc::clone(&frame)) { + Ok(imported) => return Ok(PreparedLinuxFrame::DmaBuf(imported)), + Err(error) => gpu_error = Some(error), + } + } + if frame.planes.len() == 2 { + return Ok(PreparedLinuxFrame::Cpu(CpuNv12Frame { + luma: frame.planes[0].clone(), + chroma: frame.planes[1].clone(), + source: frame, + })); + } + if let Some(vulkan) = frame.vulkan.as_ref() { + let planes = vulkan.download_nv12()?; + if planes.len() != 2 { + return Err(Error::InvalidFormat( + "Vulkan CPU fallback did not produce two NV12 planes".to_owned(), + )); + } + return Ok(PreparedLinuxFrame::Cpu(CpuNv12Frame { + luma: planes[0].clone(), + chroma: planes[1].clone(), + source: frame, + })); + } + if let Some(error) = gpu_error { + return Err(error); + } + Err(Error::unavailable( + Subsystem::Vulkan, + "decoded frame cannot be synchronized with Qt's Vulkan device and has no CPU NV12 fallback", + )) + } + + fn wait_for_decode(&self, frame: &VulkanVideoFrame) -> Result<()> { + let (semaphores, values): (Vec<_>, Vec<_>) = timeline_waits(frame)? + .into_iter() + .map(|(semaphore, value)| (vk::Semaphore::from_raw(semaphore), value)) + .unzip(); + let wait = vk::SemaphoreWaitInfo::default() + .semaphores(&semaphores) + .values(&values); + let source_instance; + let source_device; + let device = if frame.device == self.render.device { + &self.device + } else { + source_instance = unsafe { + ash::Instance::load( + self._entry.static_fn(), + vk::Instance::from_raw(frame.instance as u64), + ) + }; + source_device = unsafe { + ash::Device::load( + source_instance.fp_v1_0(), + vk::Device::from_raw(frame.device as u64), + ) + }; + &source_device + }; + unsafe { device.wait_semaphores(&wait, DECODE_WAIT_TIMEOUT.as_nanos() as u64) } + .map_err(|error| vk_error("host-wait for Vulkan decoder frame", error)) + } + + fn import_dmabuf(&mut self, source: Arc) -> Result> { + let dmabuf = source.dmabuf.as_ref().expect("DMA-BUF checked by caller"); + let (object_index, luma, chroma) = nv12_dmabuf_layout(dmabuf)?; + let object = dmabuf.objects[object_index]; + let (device, inode) = fd_identity(object.fd)?; + let key = DmaBufKey { + device, + inode, + width: source.format.width, + height: source.format.height, + modifier: object.format_modifier, + luma_offset: luma.offset, + luma_pitch: luma.pitch, + chroma_offset: chroma.offset, + chroma_pitch: chroma.pitch, + }; + if let Some(imported) = self.imported.get(&key).and_then(Weak::upgrade) { + return Ok(imported); + } + self.imported + .retain(|_, imported| imported.strong_count() != 0); + let imported = Arc::new(import_nv12_dmabuf( + &self.instance, + self.physical_device, + &self.device, + self.render.queue_family, + object.fd, + object.size, + object.format_modifier, + luma, + chroma, + source, + )?); + self.imported.insert(key, Arc::downgrade(&imported)); + Ok(imported) + } + + fn ensure_renderer(&mut self) -> Result<()> { + if self.renderer.is_some() { + return Ok(()); + } + let attachment = [vk::AttachmentDescription::default() + .format(vk::Format::R8G8B8A8_UNORM) + .samples(vk::SampleCountFlags::TYPE_1) + .load_op(vk::AttachmentLoadOp::DONT_CARE) + .store_op(vk::AttachmentStoreOp::STORE) + .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE) + .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE) + .initial_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .final_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)]; + let color_reference = [vk::AttachmentReference::default() + .attachment(0) + .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)]; + let subpasses = [vk::SubpassDescription::default() + .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS) + .color_attachments(&color_reference)]; + let render_pass = unsafe { + self.device.create_render_pass( + &vk::RenderPassCreateInfo::default() + .attachments(&attachment) + .subpasses(&subpasses), + None, + ) + } + .map_err(|error| vk_error("create embedded NV12 render pass", error))?; + let bindings = [0, 1].map(|binding| { + vk::DescriptorSetLayoutBinding::default() + .binding(binding) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::FRAGMENT) + }); + let descriptor_set_layout = unsafe { + self.device.create_descriptor_set_layout( + &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings), + None, + ) + } + .map_err(|error| vk_error("create embedded NV12 descriptor layout", error))?; + let layouts = [descriptor_set_layout]; + let push_ranges = [vk::PushConstantRange::default() + .stage_flags(vk::ShaderStageFlags::FRAGMENT) + .size(size_of::() as u32)]; + let pipeline_layout = unsafe { + self.device.create_pipeline_layout( + &vk::PipelineLayoutCreateInfo::default() + .set_layouts(&layouts) + .push_constant_ranges(&push_ranges), + None, + ) + } + .map_err(|error| vk_error("create embedded NV12 pipeline layout", error))?; + let vertex_words = ash::util::read_spv(&mut Cursor::new(NV12_VERTEX_SHADER)) + .map_err(|error| Error::backend(Subsystem::Vulkan, error.to_string()))?; + let fragment_words = ash::util::read_spv(&mut Cursor::new(NV12_FRAGMENT_SHADER)) + .map_err(|error| Error::backend(Subsystem::Vulkan, error.to_string()))?; + let vertex_module = unsafe { + self.device.create_shader_module( + &vk::ShaderModuleCreateInfo::default().code(&vertex_words), + None, + ) + } + .map_err(|error| vk_error("create embedded NV12 vertex shader", error))?; + let fragment_module = unsafe { + self.device.create_shader_module( + &vk::ShaderModuleCreateInfo::default().code(&fragment_words), + None, + ) + } + .map_err(|error| vk_error("create embedded NV12 fragment shader", error))?; + let stages = [ + vk::PipelineShaderStageCreateInfo::default() + .stage(vk::ShaderStageFlags::VERTEX) + .module(vertex_module) + .name(c"main"), + vk::PipelineShaderStageCreateInfo::default() + .stage(vk::ShaderStageFlags::FRAGMENT) + .module(fragment_module) + .name(c"main"), + ]; + let vertex_input = vk::PipelineVertexInputStateCreateInfo::default(); + let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default() + .topology(vk::PrimitiveTopology::TRIANGLE_LIST); + let viewport_state = vk::PipelineViewportStateCreateInfo::default() + .viewport_count(1) + .scissor_count(1); + let rasterization = vk::PipelineRasterizationStateCreateInfo::default() + .polygon_mode(vk::PolygonMode::FILL) + .cull_mode(vk::CullModeFlags::NONE) + .front_face(vk::FrontFace::COUNTER_CLOCKWISE) + .line_width(1.0); + let multisample = vk::PipelineMultisampleStateCreateInfo::default() + .rasterization_samples(vk::SampleCountFlags::TYPE_1); + let blend_attachments = [vk::PipelineColorBlendAttachmentState::default() + .color_write_mask(vk::ColorComponentFlags::RGBA)]; + let blend = + vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachments); + let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR]; + let dynamic = vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states); + let pipeline_info = [vk::GraphicsPipelineCreateInfo::default() + .stages(&stages) + .vertex_input_state(&vertex_input) + .input_assembly_state(&input_assembly) + .viewport_state(&viewport_state) + .rasterization_state(&rasterization) + .multisample_state(&multisample) + .color_blend_state(&blend) + .dynamic_state(&dynamic) + .layout(pipeline_layout) + .render_pass(render_pass)]; + let pipeline = unsafe { + self.device + .create_graphics_pipelines(vk::PipelineCache::null(), &pipeline_info, None) + } + .map_err(|(_, error)| vk_error("create embedded NV12 pipeline", error))?[0]; + unsafe { + self.device.destroy_shader_module(fragment_module, None); + self.device.destroy_shader_module(vertex_module, None); + } + let sampler = unsafe { + self.device.create_sampler( + &vk::SamplerCreateInfo::default() + .mag_filter(vk::Filter::LINEAR) + .min_filter(vk::Filter::LINEAR) + .mipmap_mode(vk::SamplerMipmapMode::NEAREST) + .address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE) + .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE) + .address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE), + None, + ) + } + .map_err(|error| vk_error("create embedded NV12 sampler", error))?; + let pool_sizes = [vk::DescriptorPoolSize::default() + .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .descriptor_count(self.slots.len() as u32 * 2)]; + let descriptor_pool = unsafe { + self.device.create_descriptor_pool( + &vk::DescriptorPoolCreateInfo::default() + .max_sets(self.slots.len() as u32) + .pool_sizes(&pool_sizes), + None, + ) + } + .map_err(|error| vk_error("create embedded NV12 descriptor pool", error))?; + let set_layouts = vec![descriptor_set_layout; self.slots.len()]; + let descriptor_sets = unsafe { + self.device.allocate_descriptor_sets( + &vk::DescriptorSetAllocateInfo::default() + .descriptor_pool(descriptor_pool) + .set_layouts(&set_layouts), + ) + } + .map_err(|error| vk_error("allocate embedded NV12 descriptor sets", error))?; + self.renderer = Some(RendererResources { + render_pass, + descriptor_set_layout, + pipeline_layout, + pipeline, + descriptor_pool, + descriptor_sets, + sampler, + }); + Ok(()) + } + + fn ensure_slot(&mut self, slot: usize, width: u32, height: u32) -> Result<()> { + if self.slots[slot] + .as_ref() + .is_some_and(|resources| resources.width == width && resources.height == height) + { + return Ok(()); + } + if let Some(resources) = self.slots[slot].take() { + self.destroy_slot(resources); + } + let output = create_gpu_image( + &self.instance, + self.physical_device, + &self.device, + width, + height, + vk::Format::R8G8B8A8_UNORM, + vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::SAMPLED, + )?; + let renderer = self.renderer.as_ref().expect("renderer initialized"); + let attachments = [output.view]; + let framebuffer = unsafe { + self.device.create_framebuffer( + &vk::FramebufferCreateInfo::default() + .render_pass(renderer.render_pass) + .attachments(&attachments) + .width(width) + .height(height) + .layers(1), + None, + ) + } + .map_err(|error| vk_error("create embedded RGBA framebuffer", error))?; + self.slots[slot] = Some(FrameSlotResources { + width, + height, + output, + output_initialized: false, + framebuffer, + cpu: None, + owned_input_views: Vec::new(), + frame: None, + }); + Ok(()) + } + + fn prepare_input( + &mut self, + slot: usize, + command_buffer: vk::CommandBuffer, + prepared: &PreparedLinuxFrame, + width: u32, + height: u32, + ) -> Result<(vk::ImageView, vk::ImageView)> { + let resources = self.slots[slot].as_mut().expect("slot initialized"); + resources.frame = None; + for view in resources.owned_input_views.drain(..) { + unsafe { self.device.destroy_image_view(view, None) }; + } + match prepared { + PreparedLinuxFrame::Vulkan(frame) => { + let (luma, chroma) = direct_image_views(&self.device, &frame.images)?; + resources.owned_input_views.extend([luma, chroma]); + Ok((luma, chroma)) + } + PreparedLinuxFrame::DmaBuf(frame) => Ok(( + vk::ImageView::from_raw(frame.luma_view), + vk::ImageView::from_raw(frame.chroma_view), + )), + PreparedLinuxFrame::Cpu(frame) => { + if resources.cpu.is_none() { + resources.cpu = Some(create_cpu_upload_resources( + &self.instance, + self.physical_device, + &self.device, + width, + height, + )?); + } + let cpu = resources.cpu.as_mut().expect("CPU resources initialized"); + upload_cpu_nv12(&self.device, command_buffer, cpu, frame, width, height)?; + Ok((cpu.luma.view, cpu.chroma.view)) + } + } + } + + fn update_descriptors( + &self, + slot: usize, + luma_view: vk::ImageView, + chroma_view: vk::ImageView, + ) { + let renderer = self.renderer.as_ref().expect("renderer initialized"); + let luma = [vk::DescriptorImageInfo::default() + .sampler(renderer.sampler) + .image_view(luma_view) + .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)]; + let chroma = [vk::DescriptorImageInfo::default() + .sampler(renderer.sampler) + .image_view(chroma_view) + .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)]; + let writes = [ + vk::WriteDescriptorSet::default() + .dst_set(renderer.descriptor_sets[slot]) + .dst_binding(0) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .image_info(&luma), + vk::WriteDescriptorSet::default() + .dst_set(renderer.descriptor_sets[slot]) + .dst_binding(1) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .image_info(&chroma), + ]; + unsafe { self.device.update_descriptor_sets(&writes, &[]) }; + } + + #[allow(clippy::too_many_arguments)] + unsafe fn record_conversion( + &mut self, + slot: usize, + command_buffer: vk::CommandBuffer, + prepared: &PreparedLinuxFrame, + width: u32, + height: u32, + color_matrix: crate::ColorMatrix, + full_range: bool, + ) -> Result<()> { + let resources = self.slots[slot].as_mut().expect("slot initialized"); + let renderer = self.renderer.as_ref().expect("renderer initialized"); + let mut acquire = Vec::new(); + match prepared { + PreparedLinuxFrame::Vulkan(frame) => { + acquire.extend(frame.images.iter().map(|image| { + let source_family = if image.source_queue_family == self.render.queue_family { + vk::QUEUE_FAMILY_IGNORED + } else { + image.source_queue_family + }; + vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::from_raw(image.old_layout)) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_queue_family_index(source_family) + .dst_queue_family_index(if source_family == vk::QUEUE_FAMILY_IGNORED { + vk::QUEUE_FAMILY_IGNORED + } else { + self.render.queue_family + }) + .src_access_mask(vk::AccessFlags::from_raw(image.old_access as u32)) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .image(vk::Image::from_raw(image.image)) + .subresource_range(image_color_range()) + })); + } + PreparedLinuxFrame::DmaBuf(frame) => { + acquire.push( + vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::GENERAL) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_queue_family_index(vk::QUEUE_FAMILY_EXTERNAL) + .dst_queue_family_index(self.render.queue_family) + .src_access_mask(vk::AccessFlags::MEMORY_WRITE) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .image(vk::Image::from_raw(frame.image)) + .subresource_range(image_color_range()), + ); + } + PreparedLinuxFrame::Cpu(_) => {} + } + acquire.push( + vk::ImageMemoryBarrier::default() + .old_layout(if resources.output_initialized { + vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL + } else { + vk::ImageLayout::UNDEFINED + }) + .new_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .src_access_mask(if resources.output_initialized { + vk::AccessFlags::SHADER_READ + } else { + vk::AccessFlags::empty() + }) + .dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) + .image(resources.output.image) + .subresource_range(image_color_range()), + ); + unsafe { + self.device.cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::ALL_COMMANDS, + vk::PipelineStageFlags::FRAGMENT_SHADER + | vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::DependencyFlags::empty(), + &[], + &[], + &acquire, + ); + self.device.cmd_begin_render_pass( + command_buffer, + &vk::RenderPassBeginInfo::default() + .render_pass(renderer.render_pass) + .framebuffer(resources.framebuffer) + .render_area(vk::Rect2D::default().extent(vk::Extent2D { width, height })), + vk::SubpassContents::INLINE, + ); + self.device.cmd_set_viewport( + command_buffer, + 0, + &[vk::Viewport::default() + .width(width as f32) + .height(height as f32) + .max_depth(1.0)], + ); + self.device.cmd_set_scissor( + command_buffer, + 0, + &[vk::Rect2D::default().extent(vk::Extent2D { width, height })], + ); + self.device.cmd_bind_pipeline( + command_buffer, + vk::PipelineBindPoint::GRAPHICS, + renderer.pipeline, + ); + self.device.cmd_bind_descriptor_sets( + command_buffer, + vk::PipelineBindPoint::GRAPHICS, + renderer.pipeline_layout, + 0, + &[renderer.descriptor_sets[slot]], + &[], + ); + let constants = ConversionConstants { + texture_scale: [1.0, 1.0], + color_matrix: match color_matrix { + crate::ColorMatrix::Bt601 => 0, + crate::ColorMatrix::Bt709 => 1, + crate::ColorMatrix::Bt2020 => 2, + }, + full_range: u32::from(full_range), + }; + let constants = std::slice::from_raw_parts( + (&constants as *const ConversionConstants).cast::(), + size_of::(), + ); + self.device.cmd_push_constants( + command_buffer, + renderer.pipeline_layout, + vk::ShaderStageFlags::FRAGMENT, + 0, + constants, + ); + self.device.cmd_draw(command_buffer, 3, 1, 0, 0); + self.device.cmd_end_render_pass(command_buffer); + } + let mut release = vec![ + vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .image(resources.output.image) + .subresource_range(image_color_range()), + ]; + match prepared { + PreparedLinuxFrame::Vulkan(frame) => { + release.extend(frame.images.iter().map(|image| { + let destination_family = + if image.source_queue_family == self.render.queue_family { + vk::QUEUE_FAMILY_IGNORED + } else { + image.source_queue_family + }; + vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .new_layout(vk::ImageLayout::from_raw(image.old_layout)) + .src_queue_family_index(if destination_family == vk::QUEUE_FAMILY_IGNORED { + vk::QUEUE_FAMILY_IGNORED + } else { + self.render.queue_family + }) + .dst_queue_family_index(destination_family) + .src_access_mask(vk::AccessFlags::SHADER_READ) + .dst_access_mask(vk::AccessFlags::MEMORY_READ) + .image(vk::Image::from_raw(image.image)) + .subresource_range(image_color_range()) + })); + } + PreparedLinuxFrame::DmaBuf(frame) => release.push( + vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .new_layout(vk::ImageLayout::GENERAL) + .src_queue_family_index(self.render.queue_family) + .dst_queue_family_index(vk::QUEUE_FAMILY_EXTERNAL) + .src_access_mask(vk::AccessFlags::SHADER_READ) + .dst_access_mask(vk::AccessFlags::MEMORY_READ | vk::AccessFlags::MEMORY_WRITE) + .image(vk::Image::from_raw(frame.image)) + .subresource_range(image_color_range()), + ), + PreparedLinuxFrame::Cpu(_) => {} + } + unsafe { + self.device.cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT + | vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::PipelineStageFlags::ALL_COMMANDS, + vk::DependencyFlags::empty(), + &[], + &[], + &release, + ); + } + resources.output_initialized = true; + Ok(()) + } + + fn destroy_slot(&self, mut resources: FrameSlotResources) { + resources.frame = None; + unsafe { + for view in resources.owned_input_views.drain(..) { + self.device.destroy_image_view(view, None); + } + if let Some(cpu) = resources.cpu.take() { + self.device.destroy_buffer(cpu.staging_buffer, None); + self.device.free_memory(cpu.staging_memory, None); + destroy_gpu_image(&self.device, cpu.chroma); + destroy_gpu_image(&self.device, cpu.luma); + } + self.device.destroy_framebuffer(resources.framebuffer, None); + destroy_gpu_image(&self.device, resources.output); + } + } +} + +#[repr(C)] +struct ConversionConstants { + texture_scale: [f32; 2], + color_matrix: u32, + full_range: u32, +} + +impl Drop for LinuxFrameProducer { + fn drop(&mut self) { + for resources in std::mem::take(&mut self.slots).into_iter().flatten() { + self.destroy_slot(resources); + } + if let Some(renderer) = self.renderer.take() { + unsafe { + self.device + .destroy_descriptor_pool(renderer.descriptor_pool, None); + self.device.destroy_sampler(renderer.sampler, None); + self.device.destroy_pipeline(renderer.pipeline, None); + self.device + .destroy_pipeline_layout(renderer.pipeline_layout, None); + self.device + .destroy_descriptor_set_layout(renderer.descriptor_set_layout, None); + self.device.destroy_render_pass(renderer.render_pass, None); + } + } + self.imported.clear(); + } +} + +fn direct_image_views( + device: &ash::Device, + images: &[PreparedVulkanImage], +) -> Result<(vk::ImageView, vk::ImageView)> { + if images.len() == 1 { + let image = vk::Image::from_raw(images[0].image); + let luma = create_image_view( + device, + image, + vk::Format::R8_UNORM, + vk::ImageAspectFlags::PLANE_0, + )?; + let chroma = match create_image_view( + device, + image, + vk::Format::R8G8_UNORM, + vk::ImageAspectFlags::PLANE_1, + ) { + Ok(view) => view, + Err(error) => { + unsafe { device.destroy_image_view(luma, None) }; + return Err(error); + } + }; + return Ok((luma, chroma)); + } + if images.len() != 2 { + return Err(Error::InvalidFormat( + "Vulkan NV12 frame must contain one multiplanar or two plane images".to_owned(), + )); + } + let luma = create_image_view( + device, + vk::Image::from_raw(images[0].image), + vk::Format::from_raw(images[0].format), + vk::ImageAspectFlags::COLOR, + )?; + let chroma = match create_image_view( + device, + vk::Image::from_raw(images[1].image), + vk::Format::from_raw(images[1].format), + vk::ImageAspectFlags::COLOR, + ) { + Ok(view) => view, + Err(error) => { + unsafe { device.destroy_image_view(luma, None) }; + return Err(error); + } + }; + Ok((luma, chroma)) +} + +fn create_gpu_image( + instance: &ash::Instance, + physical_device: vk::PhysicalDevice, + device: &ash::Device, + width: u32, + height: u32, + format: vk::Format, + usage: vk::ImageUsageFlags, +) -> Result { + let image = unsafe { + device.create_image( + &vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(format) + .extent(vk::Extent3D { + width, + height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(usage) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .initial_layout(vk::ImageLayout::UNDEFINED), + None, + ) + } + .map_err(|error| vk_error("create embedded GPU image", error))?; + let requirements = unsafe { device.get_image_memory_requirements(image) }; + let memory_type = find_memory_type( + instance, + physical_device, + requirements.memory_type_bits, + vk::MemoryPropertyFlags::DEVICE_LOCAL, + )?; + let memory = unsafe { + device.allocate_memory( + &vk::MemoryAllocateInfo::default() + .allocation_size(requirements.size) + .memory_type_index(memory_type), + None, + ) + } + .map_err(|error| vk_error("allocate embedded GPU image", error))?; + if let Err(error) = unsafe { device.bind_image_memory(image, memory, 0) } { + unsafe { + device.free_memory(memory, None); + device.destroy_image(image, None); + } + return Err(vk_error("bind embedded GPU image", error)); + } + let view = match create_image_view(device, image, format, vk::ImageAspectFlags::COLOR) { + Ok(view) => view, + Err(error) => { + unsafe { + device.free_memory(memory, None); + device.destroy_image(image, None); + } + return Err(error); + } + }; + Ok(GpuImage { + image, + memory, + view, + }) +} + +fn create_cpu_upload_resources( + instance: &ash::Instance, + physical_device: vk::PhysicalDevice, + device: &ash::Device, + width: u32, + height: u32, +) -> Result { + let luma = create_gpu_image( + instance, + physical_device, + device, + width, + height, + vk::Format::R8_UNORM, + vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::SAMPLED, + )?; + let chroma = create_gpu_image( + instance, + physical_device, + device, + width / 2, + height / 2, + vk::Format::R8G8_UNORM, + vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::SAMPLED, + )?; + let size = u64::from(width) * u64::from(height) * 3 / 2; + let buffer = unsafe { + device.create_buffer( + &vk::BufferCreateInfo::default() + .size(size) + .usage(vk::BufferUsageFlags::TRANSFER_SRC) + .sharing_mode(vk::SharingMode::EXCLUSIVE), + None, + ) + } + .map_err(|error| vk_error("create NV12 upload buffer", error))?; + let requirements = unsafe { device.get_buffer_memory_requirements(buffer) }; + let memory_type = find_memory_type( + instance, + physical_device, + requirements.memory_type_bits, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, + )?; + let memory = unsafe { + device.allocate_memory( + &vk::MemoryAllocateInfo::default() + .allocation_size(requirements.size) + .memory_type_index(memory_type), + None, + ) + } + .map_err(|error| vk_error("allocate NV12 upload buffer", error))?; + unsafe { device.bind_buffer_memory(buffer, memory, 0) } + .map_err(|error| vk_error("bind NV12 upload buffer", error))?; + Ok(CpuUploadResources { + luma, + chroma, + staging_buffer: buffer, + staging_memory: memory, + initialized: false, + }) +} + +fn upload_cpu_nv12( + device: &ash::Device, + command_buffer: vk::CommandBuffer, + resources: &mut CpuUploadResources, + frame: &CpuNv12Frame, + width: u32, + height: u32, +) -> Result<()> { + let luma_len = width as usize * height as usize; + let total_len = luma_len + luma_len / 2; + let mapped = unsafe { + device.map_memory( + resources.staging_memory, + 0, + total_len as u64, + vk::MemoryMapFlags::empty(), + ) + } + .map_err(|error| vk_error("map NV12 upload buffer", error))?; + unsafe { + copy_plane_rows(&frame.luma, mapped.cast(), width as usize, height as usize); + copy_plane_rows( + &frame.chroma, + mapped.cast::().add(luma_len), + width as usize, + height as usize / 2, + ); + device.unmap_memory(resources.staging_memory); + } + let source_stage = if resources.initialized { + vk::PipelineStageFlags::FRAGMENT_SHADER + } else { + vk::PipelineStageFlags::TOP_OF_PIPE + }; + let source_access = if resources.initialized { + vk::AccessFlags::SHADER_READ + } else { + vk::AccessFlags::empty() + }; + let old_layout = if resources.initialized { + vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL + } else { + vk::ImageLayout::UNDEFINED + }; + let to_transfer = [resources.luma.image, resources.chroma.image].map(|image| { + vk::ImageMemoryBarrier::default() + .old_layout(old_layout) + .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .src_access_mask(source_access) + .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE) + .image(image) + .subresource_range(image_color_range()) + }); + unsafe { + device.cmd_pipeline_barrier( + command_buffer, + source_stage, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + &[], + &[], + &to_transfer, + ); + device.cmd_copy_buffer_to_image( + command_buffer, + resources.staging_buffer, + resources.luma.image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &[vk::BufferImageCopy::default() + .buffer_offset(0) + .image_subresource( + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .layer_count(1), + ) + .image_extent(vk::Extent3D { + width, + height, + depth: 1, + })], + ); + device.cmd_copy_buffer_to_image( + command_buffer, + resources.staging_buffer, + resources.chroma.image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &[vk::BufferImageCopy::default() + .buffer_offset(luma_len as u64) + .image_subresource( + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .layer_count(1), + ) + .image_extent(vk::Extent3D { + width: width / 2, + height: height / 2, + depth: 1, + })], + ); + let to_sample = [resources.luma.image, resources.chroma.image].map(|image| { + vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_access_mask(vk::AccessFlags::TRANSFER_WRITE) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .image(image) + .subresource_range(image_color_range()) + }); + device.cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::TRANSFER, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::DependencyFlags::empty(), + &[], + &[], + &to_sample, + ); + } + resources.initialized = true; + Ok(()) +} + +unsafe fn copy_plane_rows(plane: &FramePlane, destination: *mut u8, row_bytes: usize, rows: usize) { + for row in 0..rows { + unsafe { + std::ptr::copy_nonoverlapping( + plane.data.as_ptr().add(row * plane.stride), + destination.add(row * row_bytes), + row_bytes, + ); + } + } +} + +fn create_image_view( + device: &ash::Device, + image: vk::Image, + format: vk::Format, + aspect: vk::ImageAspectFlags, +) -> Result { + unsafe { + device.create_image_view( + &vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(format) + .subresource_range( + vk::ImageSubresourceRange::default() + .aspect_mask(aspect) + .level_count(1) + .layer_count(1), + ), + None, + ) + } + .map_err(|error| vk_error("create embedded image view", error)) +} + +fn destroy_gpu_image(device: &ash::Device, image: GpuImage) { + unsafe { + device.destroy_image_view(image.view, None); + device.destroy_image(image.image, None); + device.free_memory(image.memory, None); + } +} + +fn find_memory_type( + instance: &ash::Instance, + physical_device: vk::PhysicalDevice, + memory_type_bits: u32, + required: vk::MemoryPropertyFlags, +) -> Result { + let properties = unsafe { instance.get_physical_device_memory_properties(physical_device) }; + (0..properties.memory_type_count) + .find(|index| { + memory_type_bits & (1 << index) != 0 + && properties.memory_types[*index as usize] + .property_flags + .contains(required) + }) + .ok_or_else(|| Error::unavailable(Subsystem::Vulkan, "no compatible Vulkan memory type")) +} + +fn image_color_range() -> vk::ImageSubresourceRange { + vk::ImageSubresourceRange::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .level_count(1) + .layer_count(1) +} + +fn timeline_waits(frame: &VulkanVideoFrame) -> Result> { + let mut waits = HashMap::::new(); + for image in &frame.images { + if image.semaphore == 0 || image.semaphore_value == 0 { + return Err(Error::unavailable( + Subsystem::Vulkan, + "Vulkan decoder frame has no host-waitable timeline semaphore", + )); + } + match waits.entry(image.semaphore) { + HashMapEntry::Occupied(mut entry) => { + *entry.get_mut() = (*entry.get()).max(image.semaphore_value); + } + HashMapEntry::Vacant(entry) => { + entry.insert(image.semaphore_value); + } + } + } + let mut waits = waits.into_iter().collect::>(); + waits.sort_unstable_by_key(|(semaphore, _)| *semaphore); + Ok(waits) +} + +fn nv12_dmabuf_layout(frame: &DmaBufFrame) -> Result<(usize, DmaBufPlane, DmaBufPlane)> { + let (luma, chroma) = if frame.layers.len() == 1 + && frame.layers[0].format == DRM_FORMAT_NV12 + && frame.layers[0].planes.len() >= 2 + { + (frame.layers[0].planes[0], frame.layers[0].planes[1]) + } else if frame.layers.len() >= 2 + && frame.layers[0].format == DRM_FORMAT_R8 + && frame.layers[1].format == DRM_FORMAT_GR88 + && !frame.layers[0].planes.is_empty() + && !frame.layers[1].planes.is_empty() + { + (frame.layers[0].planes[0], frame.layers[1].planes[0]) + } else { + return Err(Error::unavailable( + Subsystem::Vulkan, + "unsupported DMA-BUF NV12 plane layout", + )); + }; + if luma.object_index != chroma.object_index { + return Err(Error::unavailable( + Subsystem::Vulkan, + "disjoint multi-object NV12 DMA-BUF import is not supported", + )); + } + Ok((luma.object_index, luma, chroma)) +} + +fn fd_identity(fd: RawFd) -> Result<(u64, u64)> { + let mut metadata = std::mem::MaybeUninit::::zeroed(); + if unsafe { libc::fstat(fd, metadata.as_mut_ptr()) } != 0 { + return Err(Error::backend( + Subsystem::Vulkan, + format!( + "fstat on DMA-BUF failed: {}", + std::io::Error::last_os_error() + ), + )); + } + let metadata = unsafe { metadata.assume_init() }; + Ok((metadata.st_dev, metadata.st_ino)) +} + +#[allow(clippy::too_many_arguments)] +fn import_nv12_dmabuf( + instance: &ash::Instance, + physical_device: vk::PhysicalDevice, + device: &ash::Device, + render_queue_family: u32, + fd: RawFd, + object_size: usize, + modifier: u64, + luma: DmaBufPlane, + chroma: DmaBufPlane, + source: Arc, +) -> Result { + if luma.offset >= object_size || chroma.offset >= object_size { + return Err(Error::InvalidFormat( + "DMA-BUF plane offset exceeds its object size".to_owned(), + )); + } + let plane_layouts = [ + vk::SubresourceLayout { + offset: luma.offset as u64, + size: object_size.saturating_sub(luma.offset) as u64, + row_pitch: luma.pitch as u64, + array_pitch: 0, + depth_pitch: 0, + }, + vk::SubresourceLayout { + offset: chroma.offset as u64, + size: object_size.saturating_sub(chroma.offset) as u64, + row_pitch: chroma.pitch as u64, + array_pitch: 0, + depth_pitch: 0, + }, + ]; + let mut external = vk::ExternalMemoryImageCreateInfo::default() + .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT); + let mut drm_modifier = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default() + .drm_format_modifier(modifier) + .plane_layouts(&plane_layouts); + let image_info = vk::ImageCreateInfo::default() + .push_next(&mut external) + .push_next(&mut drm_modifier) + .flags(vk::ImageCreateFlags::MUTABLE_FORMAT) + .image_type(vk::ImageType::TYPE_2D) + .format(vk::Format::G8_B8R8_2PLANE_420_UNORM) + .extent(vk::Extent3D { + width: source.format.width, + height: source.format.height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT) + .usage(vk::ImageUsageFlags::SAMPLED) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .initial_layout(vk::ImageLayout::UNDEFINED); + let image = unsafe { device.create_image(&image_info, None) } + .map_err(|error| vk_error("create DMA-BUF NV12 image", error))?; + let requirements = unsafe { device.get_image_memory_requirements(image) }; + let fd_loader = ash::khr::external_memory_fd::Device::new(instance, device); + let mut fd_properties = vk::MemoryFdPropertiesKHR::default(); + if let Err(error) = unsafe { + fd_loader.get_memory_fd_properties( + vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT, + fd, + &mut fd_properties, + ) + } { + unsafe { device.destroy_image(image, None) }; + return Err(vk_error("query DMA-BUF memory properties", error)); + } + let memory_bits = requirements.memory_type_bits & fd_properties.memory_type_bits; + let properties = unsafe { instance.get_physical_device_memory_properties(physical_device) }; + let memory_type = (0..properties.memory_type_count) + .find(|index| memory_bits & (1 << index) != 0) + .ok_or_else(|| { + Error::unavailable(Subsystem::Vulkan, "no memory type can import DMA-BUF") + })?; + let imported_fd = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) }; + if imported_fd < 0 { + unsafe { device.destroy_image(image, None) }; + return Err(Error::backend( + Subsystem::Vulkan, + format!( + "duplicate DMA-BUF failed: {}", + std::io::Error::last_os_error() + ), + )); + } + let mut import = vk::ImportMemoryFdInfoKHR::default() + .handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT) + .fd(imported_fd); + let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().image(image); + let allocation = vk::MemoryAllocateInfo::default() + .push_next(&mut import) + .push_next(&mut dedicated) + .allocation_size(requirements.size.max(object_size as u64)) + .memory_type_index(memory_type); + let memory = match unsafe { device.allocate_memory(&allocation, None) } { + Ok(memory) => memory, + Err(error) => { + unsafe { + libc::close(imported_fd); + device.destroy_image(image, None); + } + return Err(vk_error("import DMA-BUF memory", error)); + } + }; + if let Err(error) = unsafe { device.bind_image_memory(image, memory, 0) } { + unsafe { + device.free_memory(memory, None); + device.destroy_image(image, None); + } + return Err(vk_error("bind DMA-BUF image memory", error)); + } + let luma_view = match create_plane_view( + device, + image, + vk::Format::R8_UNORM, + vk::ImageAspectFlags::PLANE_0, + ) { + Ok(view) => view, + Err(error) => { + unsafe { + device.free_memory(memory, None); + device.destroy_image(image, None); + } + return Err(error); + } + }; + let chroma_view = match create_plane_view( + device, + image, + vk::Format::R8G8_UNORM, + vk::ImageAspectFlags::PLANE_1, + ) { + Ok(view) => view, + Err(error) => { + unsafe { + device.destroy_image_view(luma_view, None); + device.free_memory(memory, None); + device.destroy_image(image, None); + } + return Err(error); + } + }; + Ok(ImportedNv12Frame { + image: image.as_raw(), + luma_view: luma_view.as_raw(), + chroma_view: chroma_view.as_raw(), + modifier, + external_queue_family: vk::QUEUE_FAMILY_EXTERNAL, + render_queue_family, + image_handle: image, + luma_view_handle: luma_view, + chroma_view_handle: chroma_view, + memory, + device: device.clone(), + source, + }) +} + +fn create_plane_view( + device: &ash::Device, + image: vk::Image, + format: vk::Format, + aspect: vk::ImageAspectFlags, +) -> Result { + let range = vk::ImageSubresourceRange::default() + .aspect_mask(aspect) + .level_count(1) + .layer_count(1); + let create = vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(format) + .subresource_range(range); + unsafe { device.create_image_view(&create, None) } + .map_err(|error| vk_error("create DMA-BUF plane image view", error)) +} + +fn vk_error(context: &'static str, error: vk::Result) -> Error { + Error::backend(Subsystem::Vulkan, format!("{context}: {error:?}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DmaBufLayer, DmaBufObject, VulkanImage}; + + fn vulkan_frame(images: Vec) -> VulkanVideoFrame { + VulkanVideoFrame::new(1, 2, 3, vec![0], 0, 0, images, 0, None, None, Arc::new(())) + } + + #[test] + fn timeline_waits_deduplicate_images_and_keep_latest_value() { + let frame = vulkan_frame(vec![ + VulkanImage { + image: 10, + format: 1, + width: 4, + height: 4, + layout: 1, + access: 1, + semaphore: 22, + semaphore_value: 7, + queue_family: 0, + }, + VulkanImage { + image: 11, + format: 2, + width: 2, + height: 2, + layout: 1, + access: 1, + semaphore: 22, + semaphore_value: 9, + queue_family: 0, + }, + ]); + assert_eq!(timeline_waits(&frame).unwrap(), vec![(22, 9)]); + } + + #[test] + fn frame_without_timeline_signal_cannot_be_rendered_unsynchronized() { + let frame = vulkan_frame(vec![VulkanImage { + image: 10, + format: 1, + width: 4, + height: 4, + layout: 1, + access: 1, + semaphore: 0, + semaphore_value: 0, + queue_family: 0, + }]); + assert!(timeline_waits(&frame).is_err()); + } + + #[test] + fn dmabuf_layout_accepts_single_and_split_nv12_layers() { + let object = DmaBufObject { + fd: 0, + size: 4096, + format_modifier: 12, + }; + let luma = DmaBufPlane { + object_index: 0, + offset: 0, + pitch: 64, + }; + let chroma = DmaBufPlane { + object_index: 0, + offset: 2048, + pitch: 64, + }; + let packed = DmaBufFrame::new( + vec![object], + vec![DmaBufLayer { + format: DRM_FORMAT_NV12, + planes: vec![luma, chroma], + }], + Arc::new(()), + ); + assert_eq!(nv12_dmabuf_layout(&packed).unwrap(), (0, luma, chroma)); + + let split = DmaBufFrame::new( + vec![object], + vec![ + DmaBufLayer { + format: DRM_FORMAT_R8, + planes: vec![luma], + }, + DmaBufLayer { + format: DRM_FORMAT_GR88, + planes: vec![chroma], + }, + ], + Arc::new(()), + ); + assert_eq!(nv12_dmabuf_layout(&split).unwrap(), (0, luma, chroma)); + } + + #[test] + fn dmabuf_layout_rejects_disjoint_objects() { + let frame = DmaBufFrame::new( + vec![ + DmaBufObject { + fd: 0, + size: 4096, + format_modifier: 0, + }, + DmaBufObject { + fd: 1, + size: 4096, + format_modifier: 0, + }, + ], + vec![DmaBufLayer { + format: DRM_FORMAT_NV12, + planes: vec![ + DmaBufPlane { + object_index: 0, + offset: 0, + pitch: 64, + }, + DmaBufPlane { + object_index: 1, + offset: 0, + pitch: 64, + }, + ], + }], + Arc::new(()), + ); + assert!(nv12_dmabuf_layout(&frame).is_err()); + } +} diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/lib.rs index 09f40ec3e..a465da16b 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/lib.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/lib.rs @@ -13,6 +13,8 @@ mod error; #[cfg(target_os = "linux")] mod format; #[cfg(all(target_os = "linux", feature = "vulkan"))] +mod frame_producer; +#[cfg(all(target_os = "linux", feature = "vulkan"))] mod presentation; #[cfg(target_os = "linux")] mod queue; @@ -37,6 +39,12 @@ pub use format::{ VideoCodec, VulkanImage, VulkanVideoFrame, }; #[cfg(all(target_os = "linux", feature = "vulkan"))] +pub use frame_producer::{ + CpuNv12Frame, ImportedNv12Frame, LinuxFrameProducer, LinuxGpuFrame, LinuxGpuFrameProducer, + PreparedLinuxFrame, PreparedVulkanFrame, PreparedVulkanImage, RecordedGpuFrame, + VulkanRenderDevice, +}; +#[cfg(all(target_os = "linux", feature = "vulkan"))] pub use presentation::{NativeSurface, VulkanPresenter}; #[cfg(target_os = "linux")] pub use session::{ diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/video/ffmpeg.rs b/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/video/ffmpeg.rs index f14a63a52..0cedefb29 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/video/ffmpeg.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-linux/src/video/ffmpeg.rs @@ -1,5 +1,5 @@ use std::ptr; -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use ffmpeg::codec; use ffmpeg::ffi; @@ -210,23 +210,23 @@ impl FfmpegDecoder { } fn convert_frame(&mut self, decoded: &frame::Video) -> Result { + let mut direct_vulkan = None; if self.mode == FfmpegMode::Vulkan && self .wanted_hw_format .as_deref() .is_some_and(|selection| Pixel::from(selection.pixel_format) == decoded.format()) { - if let Ok(frame) = map_vulkan_frame_direct(decoded, self.last_timestamp_us) { - if !self.zero_copy_active { - eprintln!("Vulkan Video same-device zero-copy enabled"); - self.zero_copy_active = true; - } - return Ok(frame); - } + direct_vulkan = map_vulkan_frame_direct(decoded, self.last_timestamp_us) + .ok() + .and_then(|frame| frame.vulkan); match map_vulkan_frame_to_dmabuf(decoded, self.last_timestamp_us) { - Ok(frame) => { + Ok(mut frame) => { + frame.vulkan = direct_vulkan.clone(); if !self.zero_copy_active { - eprintln!("Vulkan Video zero-copy enabled through DRM PRIME/DMA-BUF"); + eprintln!( + "Vulkan Video frames expose same-device and DRM PRIME/DMA-BUF paths" + ); self.zero_copy_active = true; } return Ok(frame); @@ -346,7 +346,7 @@ impl FfmpegDecoder { }, ], dmabuf: None, - vulkan: None, + vulkan: direct_vulkan, timestamp_us, }) } @@ -431,6 +431,7 @@ fn map_vulkan_frame_direct( "failed to retain Vulkan Video frame for presentation", )); } + let cpu_fallback = vulkan_cpu_nv12_fallback(decoded)?; let device = unsafe { VulkanVideoFrame::new( (*vulkan_device).inst as usize, @@ -455,7 +456,8 @@ fn map_vulkan_frame_direct( }), Arc::new(retained), ) - }; + } + .with_cpu_nv12_fallback(cpu_fallback); let output_format = StreamFormat { width, height, @@ -479,6 +481,74 @@ fn map_vulkan_frame_direct( Ok(output) } +fn vulkan_cpu_nv12_fallback( + decoded: &frame::Video, +) -> Result Result> + Send + Sync>> { + let mut retained = frame::Video::empty(); + if unsafe { ffi::av_frame_ref(retained.as_mut_ptr(), decoded.as_ptr()) } < 0 { + return Err(Error::backend( + Subsystem::Ffmpeg, + "failed to retain Vulkan frame for CPU fallback", + )); + } + let retained = Mutex::new(retained); + Ok(Arc::new(move || { + let source = retained.lock().unwrap_or_else(|poison| poison.into_inner()); + let mut transferred = frame::Video::empty(); + let transfer_result = + unsafe { ffi::av_hwframe_transfer_data(transferred.as_mut_ptr(), source.as_ptr(), 0) }; + if transfer_result < 0 { + return Err(ffmpeg_error( + "Vulkan frame CPU fallback download failed".to_owned(), + transfer_result, + )); + } + let width = transferred.width().max(1); + let height = transferred.height().max(1); + let converted; + let nv12 = if transferred.format() == Pixel::NV12 { + &transferred + } else { + let mut scaler = Scaler::get( + transferred.format(), + width, + height, + Pixel::NV12, + width, + height, + ScaleFlags::BILINEAR, + ) + .map_err(|error| { + Error::backend( + Subsystem::Ffmpeg, + format!("Vulkan CPU fallback conversion setup failed: {error}"), + ) + })?; + let mut output = frame::Video::empty(); + scaler.run(&transferred, &mut output).map_err(|error| { + Error::backend( + Subsystem::Ffmpeg, + format!("Vulkan CPU fallback NV12 conversion failed: {error}"), + ) + })?; + converted = output; + &converted + }; + Ok(vec![ + FramePlane { + data: Arc::from(nv12.data(0).to_vec()), + stride: nv12.stride(0), + rows: height as usize, + }, + FramePlane { + data: Arc::from(nv12.data(1).to_vec()), + stride: nv12.stride(1), + rows: height as usize / 2, + }, + ]) + })) +} + fn map_vulkan_frame_to_dmabuf( decoded: &frame::Video, fallback_timestamp_us: u64, diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-linux/tests/linux_api.rs b/native/opennow-streamer/crates/opennow-streamer-platform-linux/tests/linux_api.rs index 3e6e8669c..a650fd9a7 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-linux/tests/linux_api.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-linux/tests/linux_api.rs @@ -10,6 +10,10 @@ use std::sync::Arc; assert_impl_all!(LinuxSession: Send); #[cfg(feature = "vulkan")] +assert_impl_all!(opennow_streamer_platform_linux::LinuxGpuFrame: Send, Sync); +#[cfg(feature = "vulkan")] +assert_impl_all!(opennow_streamer_platform_linux::LinuxGpuFrameProducer: Send, Sync); +#[cfg(feature = "vulkan")] assert_not_impl_any!(opennow_streamer_platform_linux::NativeSurface<'static>: Send, Sync); #[test] @@ -28,3 +32,57 @@ fn linux_compile_target_is_supported() { let _: fn() -> opennow_streamer_platform_linux::CapabilityReport = opennow_streamer_platform_linux::probe_capabilities; } + +#[cfg(feature = "vulkan")] +#[test] +fn embedded_gpu_producer_bounds_qrhi_frame_slots() { + use opennow_streamer_platform_linux::LinuxGpuFrameProducer; + + assert!(LinuxGpuFrameProducer::new(1).is_ok()); + assert!(LinuxGpuFrameProducer::new(8).is_ok()); + assert!(LinuxGpuFrameProducer::new(0).is_err()); + assert!(LinuxGpuFrameProducer::new(9).is_err()); +} + +#[cfg(feature = "vulkan")] +#[test] +fn embedded_gpu_frames_keep_sequence_and_presentation_time() { + use opennow_streamer_platform_linux::{ + ChromaLocation, ColorMatrix, ColorRange, DecodedVideoFrame, FramePlane, + LinuxGpuFrameProducer, PixelFormat, + }; + + let producer = LinuxGpuFrameProducer::new(3).unwrap(); + let decoded = DecodedVideoFrame { + format: StreamFormat { + width: 4, + height: 4, + pixel_format: PixelFormat::Nv12, + color_range: ColorRange::Limited, + color_matrix: ColorMatrix::Bt709, + chroma_location: ChromaLocation::Left, + }, + planes: vec![ + FramePlane { + data: Arc::from(vec![16_u8; 16]), + stride: 4, + rows: 4, + }, + FramePlane { + data: Arc::from(vec![128_u8; 8]), + stride: 4, + rows: 2, + }, + ], + dmabuf: None, + vulkan: None, + timestamp_us: 42, + }; + let first = producer.frame(decoded.clone()).unwrap(); + let second = producer.frame(decoded).unwrap(); + + assert_eq!(first.sequence(), 1); + assert_eq!(second.sequence(), 2); + assert_eq!(first.presentation_time_ns(), 42_000); + assert_eq!((first.width(), first.height()), (4, 4)); +} diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/format.rs b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/format.rs index cba06b050..b13dcd887 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/format.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/format.rs @@ -478,6 +478,20 @@ pub struct BackendConfig { pub queues: QueueLimits, } +#[derive(Clone, Debug, PartialEq)] +pub struct EmbeddedBackendConfig { + pub video: VideoFormat, + pub audio: AudioFormat, + pub queues: QueueLimits, +} + +impl EmbeddedBackendConfig { + pub(crate) fn validate(&self) -> Result<(), FormatError> { + self.audio.validate()?; + self.queues.validate() + } +} + impl BackendConfig { pub(crate) fn validate(&self) -> Result<(), FormatError> { self.audio.validate()?; diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/lib.rs index f96b0d165..683634b09 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/lib.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/lib.rs @@ -40,10 +40,10 @@ mod macos; pub use failure::{BackendFailure, BackendSubsystem, VideoDecodeLoss}; pub use format::{ - AudioFormat, Av1Format, BackendConfig, BorrowedNsView, BorrowedNsWindow, FrameTiming, - H264Format, H264Framing, H264ParameterSets, H265Format, H265ParameterSets, OwnedOverlayConfig, - QueueLimits, RendererRect, ScreenRect, SurfaceTarget, VideoColorSpace, VideoFormat, - WindowSurfaceConfig, + AudioFormat, Av1Format, BackendConfig, BorrowedNsView, BorrowedNsWindow, EmbeddedBackendConfig, + FrameTiming, H264Format, H264Framing, H264ParameterSets, H265Format, H265ParameterSets, + OwnedOverlayConfig, QueueLimits, RendererRect, ScreenRect, SurfaceTarget, VideoColorSpace, + VideoFormat, WindowSurfaceConfig, }; pub use lifecycle::BackendState; @@ -56,7 +56,8 @@ pub(crate) const fn overlay_should_be_ordered( #[cfg(target_os = "macos")] pub use macos::{ - BackendError, BackendStats, MacOsBackend, NativeSurfaceHandle, StreamSink, SubmitOutcome, + AdoptedMetalContext, BackendError, BackendStats, EmbeddedFrameProducer, MacOsBackend, + MetalFrame, MetalRecordedFrame, NativeSurfaceHandle, StreamSink, SubmitOutcome, activate_stream_application, probe_av1_hardware, probe_h264_hardware, probe_h265_hardware, pump_app_events, }; diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/embedded.rs b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/embedded.rs new file mode 100644 index 000000000..f6bd567cc --- /dev/null +++ b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/embedded.rs @@ -0,0 +1,585 @@ +use std::collections::HashMap; +use std::ffi::c_void; +use std::ptr::{self, NonNull}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use objc2::Message; +use objc2::rc::Retained; +use objc2::runtime::ProtocolObject; +use objc2_core_foundation::CFRetained; +use objc2_core_video::{ + CVMetalTexture, CVMetalTextureCache, CVMetalTextureGetTexture, CVPixelBufferGetHeightOfPlane, + CVPixelBufferGetIOSurface, CVPixelBufferGetPixelFormatType, CVPixelBufferGetPlaneCount, + CVPixelBufferGetWidthOfPlane, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + kCVPixelFormatType_420YpCbCr10BiPlanarFullRange, + kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, +}; +use objc2_foundation::NSString; +use objc2_metal::{ + MTLCommandBuffer, MTLCommandEncoder, MTLDevice, MTLLibrary, MTLLoadAction, MTLPixelFormat, + MTLPrimitiveType, MTLRenderCommandEncoder, MTLRenderPassDescriptor, + MTLRenderPipelineDescriptor, MTLRenderPipelineState, MTLStorageMode, MTLStoreAction, + MTLTexture, MTLTextureDescriptor, MTLTextureUsage, MTLViewport, +}; + +use crate::format::VideoColorSpace; + +use super::mailbox::LatestMailbox; +use super::video::DecodedFrame; +use super::{BackendError, Counters}; + +const MAX_RETAINED_FRAME_SLOTS: usize = 8; + +fn validate_frame_slot(frame_slot: u32) -> Result<(), BackendError> { + if frame_slot < MAX_RETAINED_FRAME_SLOTS as u32 { + Ok(()) + } else { + Err(BackendError::Metal(format!( + "Metal frame slot {frame_slot} exceeds the eight-slot limit" + ))) + } +} + +const SHADER_SOURCE: &str = r#" +#include +using namespace metal; + +struct VertexOut { + float4 position [[position]]; + float2 texcoord; +}; + +struct ConversionParameters { + uint color_space; + uint sample_format; + uint full_range; +}; + +vertex VertexOut embedded_video_vertex(uint vertex_id [[vertex_id]]) { + const float2 positions[3] = { float2(-1.0, -1.0), float2(3.0, -1.0), float2(-1.0, 3.0) }; + const float2 texcoords[3] = { float2(0.0, 1.0), float2(2.0, 1.0), float2(0.0, -1.0) }; + VertexOut out; + out.position = float4(positions[vertex_id], 0.0, 1.0); + out.texcoord = texcoords[vertex_id]; + return out; +} + +fragment float4 embedded_video_fragment( + VertexOut in [[stage_in]], + texture2d luma [[texture(0)]], + texture2d chroma [[texture(1)]], + constant ConversionParameters ¶meters [[buffer(0)]]) { + constexpr sampler linear_sampler(coord::normalized, address::clamp_to_edge, filter::linear); + float y = luma.sample(linear_sampler, in.texcoord).r; + float2 cbcr = chroma.sample(linear_sampler, in.texcoord).rg; + if (parameters.full_range == 0) { + if (parameters.sample_format == 0) { + y = (y - (16.0 / 255.0)) * (255.0 / 219.0); + cbcr -= float2(0.5); + } else { + y = (y - (64.0 / 1023.0)) * (1023.0 / 876.0); + cbcr -= float2(512.0 / 1023.0); + } + } else { + cbcr -= parameters.sample_format == 0 + ? float2(128.0 / 255.0) + : float2(512.0 / 1023.0); + } + float3 rgb; + if (parameters.color_space == 0) { + rgb = float3( + y + 1.596027 * cbcr.y, + y - 0.391762 * cbcr.x - 0.812968 * cbcr.y, + y + 2.017232 * cbcr.x); + } else { + rgb = float3( + y + 1.792741 * cbcr.y, + y - 0.213249 * cbcr.x - 0.532909 * cbcr.y, + y + 2.112402 * cbcr.x); + } + return float4(saturate(rgb), 1.0); +} +"#; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BiPlanarFormat { + Nv12VideoRange, + Nv12FullRange, + P010VideoRange, + P010FullRange, +} + +impl BiPlanarFormat { + fn from_pixel_format(pixel_format: u32) -> Option { + if pixel_format == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange { + Some(Self::Nv12VideoRange) + } else if pixel_format == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange { + Some(Self::Nv12FullRange) + } else if pixel_format == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange { + Some(Self::P010VideoRange) + } else if pixel_format == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange { + Some(Self::P010FullRange) + } else { + None + } + } + + const fn plane_formats(self) -> (MTLPixelFormat, MTLPixelFormat) { + match self { + Self::Nv12VideoRange | Self::Nv12FullRange => { + (MTLPixelFormat::R8Unorm, MTLPixelFormat::RG8Unorm) + } + Self::P010VideoRange | Self::P010FullRange => { + (MTLPixelFormat::R16Unorm, MTLPixelFormat::RG16Unorm) + } + } + } + + const fn parameters(self, color_space: VideoColorSpace) -> ConversionParameters { + ConversionParameters { + color_space: match color_space { + VideoColorSpace::Bt601 => 0, + VideoColorSpace::Bt709 => 1, + }, + sample_format: match self { + Self::Nv12VideoRange | Self::Nv12FullRange => 0, + Self::P010VideoRange | Self::P010FullRange => 1, + }, + full_range: match self { + Self::Nv12VideoRange | Self::P010VideoRange => 0, + Self::Nv12FullRange | Self::P010FullRange => 1, + }, + } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct ConversionParameters { + color_space: u32, + sample_format: u32, + full_range: u32, +} + +#[derive(Clone, Copy, Debug)] +pub struct AdoptedMetalContext { + pub device: *mut c_void, + pub command_buffer: *mut c_void, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MetalRecordedFrame { + pub texture: *mut c_void, + pub width: u32, + pub height: u32, + pub frame_slot: u32, + pub generation: u64, + pub presentation_time_ns: u64, +} + +pub struct MetalFrame { + frame: DecodedFrame, + state: Arc>>, + counters: Arc, + sequence: u64, +} + +impl MetalFrame { + pub fn width(&self) -> u32 { + u32::try_from(CVPixelBufferGetWidthOfPlane(&self.frame.image, 0)).unwrap_or(0) + } + + pub fn height(&self) -> u32 { + u32::try_from(CVPixelBufferGetHeightOfPlane(&self.frame.image, 0)).unwrap_or(0) + } + + pub const fn sequence(&self) -> u64 { + self.sequence + } + + pub fn presentation_time_ns(&self) -> u64 { + u64::try_from(self.frame.timestamp_100ns.max(0)) + .unwrap_or(0) + .saturating_mul(100) + } + + /// Records IOSurface plane sampling and YUV-to-RGBA conversion into a retained slot texture. + /// + /// The function creates no command queue and does not commit or wait for the command buffer. + /// It must run before Qt begins the render pass that samples the returned texture. + /// + /// # Safety + /// + /// Both pointers must identify live Metal objects from the same Qt QRhi frame, and the command + /// buffer must be open for encoding. `frame_slot` may be reused only when Qt has retired its + /// previous GPU work for that slot. + pub unsafe fn record( + &self, + adopted: AdoptedMetalContext, + frame_slot: u32, + ) -> Result { + if adopted.device.is_null() || adopted.command_buffer.is_null() { + return Err(BackendError::Metal( + "Qt supplied a null Metal device or command buffer".into(), + )); + } + validate_frame_slot(frame_slot)?; + let device_ptr = NonNull::new(adopted.device) + .expect("validated device") + .cast::>(); + let command_buffer_ptr = NonNull::new(adopted.command_buffer) + .expect("validated command buffer") + .cast::>(); + let device = unsafe { device_ptr.as_ref() }; + let command_buffer = unsafe { command_buffer_ptr.as_ref() }; + + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let device_identity = device as *const _ as *const () as usize; + if state + .as_ref() + .is_none_or(|state| state.device_identity != device_identity) + { + *state = Some(MetalState::new(device, device_identity)?); + } + let recorded = state.as_mut().expect("initialized Metal state").record( + self.frame.clone(), + command_buffer, + frame_slot, + )?; + self.counters + .video_metal_submitted + .fetch_add(1, Ordering::Relaxed); + Ok(MetalRecordedFrame { + presentation_time_ns: self.presentation_time_ns(), + ..recorded + }) + } +} + +#[derive(Clone)] +pub struct EmbeddedFrameProducer { + mailbox: Arc>, + state: Arc>>, + sequence: Arc, + counters: Arc, +} + +impl EmbeddedFrameProducer { + pub(super) fn new(mailbox: Arc>, counters: Arc) -> Self { + Self { + mailbox, + state: Arc::new(Mutex::new(None)), + sequence: Arc::new(AtomicU64::new(0)), + counters, + } + } + + pub fn acquire_latest(&self) -> Option { + let frame = self.mailbox.take()?; + let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1; + Some(MetalFrame { + frame, + state: Arc::clone(&self.state), + counters: Arc::clone(&self.counters), + sequence, + }) + } + + pub fn clear(&self) -> bool { + self.mailbox.clear() + } + + /// Releases device-bound caches after Qt has invalidated and drained its scene graph. + pub fn release_graphics_resources(&self) { + *self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + } + + pub(super) fn mailbox(&self) -> &Arc> { + &self.mailbox + } + + pub(super) fn counters(&self) -> &Arc { + &self.counters + } +} + +struct SlotResources { + output: Retained>, + _frame: DecodedFrame, + _luma_cv_texture: CFRetained, + _chroma_cv_texture: CFRetained, + _luma_texture: Retained>, + _chroma_texture: Retained>, +} + +struct MetalState { + device_identity: usize, + _device: Retained>, + pipeline: Retained>, + texture_cache: CFRetained, + slots: HashMap, + generation: u64, +} + +unsafe impl Send for MetalState {} + +impl MetalState { + fn new( + device: &ProtocolObject, + device_identity: usize, + ) -> Result { + let device = device.retain(); + let source = NSString::from_str(SHADER_SOURCE); + let library = device + .newLibraryWithSource_options_error(&source, None) + .map_err(|error| BackendError::Metal(error.localizedDescription().to_string()))?; + let vertex = library + .newFunctionWithName(&NSString::from_str("embedded_video_vertex")) + .ok_or_else(|| BackendError::Metal("embedded_video_vertex shader is missing".into()))?; + let fragment = library + .newFunctionWithName(&NSString::from_str("embedded_video_fragment")) + .ok_or_else(|| { + BackendError::Metal("embedded_video_fragment shader is missing".into()) + })?; + let descriptor = MTLRenderPipelineDescriptor::new(); + descriptor.setVertexFunction(Some(&vertex)); + descriptor.setFragmentFunction(Some(&fragment)); + let attachments = descriptor.colorAttachments(); + let attachment = unsafe { attachments.objectAtIndexedSubscript(0) }; + attachment.setPixelFormat(MTLPixelFormat::RGBA8Unorm); + let pipeline = device + .newRenderPipelineStateWithDescriptor_error(&descriptor) + .map_err(|error| BackendError::Metal(error.localizedDescription().to_string()))?; + + let mut cache_ptr = ptr::null_mut(); + let status = unsafe { + CVMetalTextureCache::create(None, None, &device, None, NonNull::from(&mut cache_ptr)) + }; + if status != 0 { + return Err(BackendError::AppleApi { + api: "CVMetalTextureCacheCreate", + status, + }); + } + let cache_ptr = NonNull::new(cache_ptr).ok_or(BackendError::AppleApi { + api: "CVMetalTextureCacheCreate", + status: -1, + })?; + Ok(Self { + device_identity, + _device: device, + pipeline, + texture_cache: unsafe { CFRetained::from_raw(cache_ptr) }, + slots: HashMap::with_capacity(3), + generation: 0, + }) + } + + fn record( + &mut self, + frame: DecodedFrame, + command_buffer: &ProtocolObject, + frame_slot: u32, + ) -> Result { + if CVPixelBufferGetPlaneCount(&frame.image) != 2 { + return Err(BackendError::Metal( + "VideoToolbox returned a non-bi-planar pixel buffer".into(), + )); + } + if CVPixelBufferGetIOSurface(Some(&frame.image)).is_none() { + return Err(BackendError::Metal( + "VideoToolbox returned a pixel buffer without IOSurface backing".into(), + )); + } + let format = + BiPlanarFormat::from_pixel_format(CVPixelBufferGetPixelFormatType(&frame.image)) + .ok_or_else(|| { + BackendError::Metal("VideoToolbox returned neither NV12 nor P010".into()) + })?; + let width = CVPixelBufferGetWidthOfPlane(&frame.image, 0); + let height = CVPixelBufferGetHeightOfPlane(&frame.image, 0); + let chroma_width = CVPixelBufferGetWidthOfPlane(&frame.image, 1); + let chroma_height = CVPixelBufferGetHeightOfPlane(&frame.image, 1); + let (luma_format, chroma_format) = format.plane_formats(); + let luma_cv_texture = self.make_plane_texture(&frame, luma_format, width, height, 0)?; + let chroma_cv_texture = + self.make_plane_texture(&frame, chroma_format, chroma_width, chroma_height, 1)?; + let luma_texture = CVMetalTextureGetTexture(&luma_cv_texture) + .ok_or_else(|| BackendError::Metal("failed to get Metal luma texture".into()))?; + let chroma_texture = CVMetalTextureGetTexture(&chroma_cv_texture) + .ok_or_else(|| BackendError::Metal("failed to get Metal chroma texture".into()))?; + + let output = self + .slots + .remove(&frame_slot) + .map(|resources| resources.output) + .filter(|texture| texture.width() == width && texture.height() == height) + .map_or_else( + || { + let descriptor = unsafe { + MTLTextureDescriptor::texture2DDescriptorWithPixelFormat_width_height_mipmapped( + MTLPixelFormat::RGBA8Unorm, + width, + height, + false, + ) + }; + descriptor.setStorageMode(MTLStorageMode::Private); + descriptor.setUsage( + MTLTextureUsage::RenderTarget | MTLTextureUsage::ShaderRead, + ); + self._device + .newTextureWithDescriptor(&descriptor) + .ok_or_else(|| { + BackendError::Metal("failed to create embedded RGBA texture".into()) + }) + }, + Ok, + )?; + + let render_pass = MTLRenderPassDescriptor::renderPassDescriptor(); + let attachments = render_pass.colorAttachments(); + let attachment = unsafe { attachments.objectAtIndexedSubscript(0) }; + attachment.setTexture(Some(&output)); + attachment.setLoadAction(MTLLoadAction::DontCare); + attachment.setStoreAction(MTLStoreAction::Store); + let encoder = command_buffer + .renderCommandEncoderWithDescriptor(&render_pass) + .ok_or_else(|| BackendError::Metal("failed to create embedded Metal encoder".into()))?; + encoder.setRenderPipelineState(&self.pipeline); + unsafe { + encoder.setFragmentTexture_atIndex(Some(&luma_texture), 0); + encoder.setFragmentTexture_atIndex(Some(&chroma_texture), 1); + } + let parameters = format.parameters(frame.color_space); + unsafe { + encoder.setFragmentBytes_length_atIndex( + NonNull::from(¶meters).cast::(), + std::mem::size_of_val(¶meters), + 0, + ); + } + encoder.setViewport(MTLViewport { + originX: 0.0, + originY: 0.0, + width: width as f64, + height: height as f64, + znear: 0.0, + zfar: 1.0, + }); + unsafe { encoder.drawPrimitives_vertexStart_vertexCount(MTLPrimitiveType::Triangle, 0, 3) }; + encoder.endEncoding(); + + let texture = Retained::as_ptr(&output).cast_mut().cast::(); + self.slots.insert( + frame_slot, + SlotResources { + output, + _frame: frame, + _luma_cv_texture: luma_cv_texture, + _chroma_cv_texture: chroma_cv_texture, + _luma_texture: luma_texture, + _chroma_texture: chroma_texture, + }, + ); + self.generation = self.generation.wrapping_add(1); + Ok(MetalRecordedFrame { + texture, + width: u32::try_from(width).unwrap_or(u32::MAX), + height: u32::try_from(height).unwrap_or(u32::MAX), + frame_slot, + generation: self.generation, + presentation_time_ns: 0, + }) + } + + fn make_plane_texture( + &self, + frame: &DecodedFrame, + pixel_format: MTLPixelFormat, + width: usize, + height: usize, + plane: usize, + ) -> Result, BackendError> { + let mut texture_ptr = ptr::null_mut(); + let status = unsafe { + CVMetalTextureCache::create_texture_from_image( + None, + &self.texture_cache, + &frame.image, + None, + pixel_format, + width, + height, + plane, + NonNull::from(&mut texture_ptr), + ) + }; + if status != 0 { + return Err(BackendError::AppleApi { + api: "CVMetalTextureCacheCreateTextureFromImage", + status, + }); + } + let texture_ptr = NonNull::new(texture_ptr).ok_or(BackendError::AppleApi { + api: "CVMetalTextureCacheCreateTextureFromImage", + status: -1, + })?; + Ok(unsafe { CFRetained::from_raw(texture_ptr) }) + } +} + +impl Drop for MetalState { + fn drop(&mut self) { + self.slots.clear(); + self.texture_cache.flush(0); + } +} + +#[cfg(test)] +mod tests { + use super::{BiPlanarFormat, MAX_RETAINED_FRAME_SLOTS, validate_frame_slot}; + use objc2_core_video::{ + kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + kCVPixelFormatType_420YpCbCr10BiPlanarFullRange, + kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, + }; + + #[test] + fn accepts_nv12_and_p010_ranges_only() { + assert_eq!( + BiPlanarFormat::from_pixel_format(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange), + Some(BiPlanarFormat::Nv12VideoRange) + ); + assert_eq!( + BiPlanarFormat::from_pixel_format(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange), + Some(BiPlanarFormat::Nv12FullRange) + ); + assert_eq!( + BiPlanarFormat::from_pixel_format(kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange), + Some(BiPlanarFormat::P010VideoRange) + ); + assert_eq!( + BiPlanarFormat::from_pixel_format(kCVPixelFormatType_420YpCbCr10BiPlanarFullRange), + Some(BiPlanarFormat::P010FullRange) + ); + assert_eq!(BiPlanarFormat::from_pixel_format(0), None); + } + + #[test] + fn retained_slot_bound_matches_public_embedded_limit() { + assert_eq!(MAX_RETAINED_FRAME_SLOTS, 8); + assert!(validate_frame_slot(0).is_ok()); + assert!(validate_frame_slot(7).is_ok()); + assert!(validate_frame_slot(8).is_err()); + } +} diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/mailbox.rs b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/mailbox.rs new file mode 100644 index 000000000..3f9e78402 --- /dev/null +++ b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/mailbox.rs @@ -0,0 +1,55 @@ +use std::sync::Mutex; + +pub(super) struct LatestMailbox { + value: Mutex>, +} + +impl LatestMailbox { + pub(super) fn new() -> Self { + Self { + value: Mutex::new(None), + } + } + + pub(super) fn replace(&self, value: T) -> bool { + self.value + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .replace(value) + .is_some() + } + + pub(super) fn take(&self) -> Option { + self.value + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + } + + pub(super) fn clear(&self) -> bool { + self.take().is_some() + } +} + +#[cfg(test)] +mod tests { + use super::LatestMailbox; + + #[test] + fn replaces_stale_value_and_consumes_latest_once() { + let mailbox = LatestMailbox::new(); + assert!(!mailbox.replace(1)); + assert!(mailbox.replace(2)); + assert_eq!(mailbox.take(), Some(2)); + assert_eq!(mailbox.take(), None); + } + + #[test] + fn clear_reports_whether_a_value_was_discarded() { + let mailbox = LatestMailbox::new(); + assert!(!mailbox.clear()); + mailbox.replace(3); + assert!(mailbox.clear()); + assert!(!mailbox.clear()); + } +} diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/mod.rs b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/mod.rs index 91d13f89a..08c16d0ac 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/mod.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/mod.rs @@ -1,4 +1,6 @@ mod audio; +mod embedded; +mod mailbox; mod presentation; mod surface; mod video; @@ -14,16 +16,21 @@ use thiserror::Error; use crate::failure::{BackendFailure, FailureReporter, VideoDecodeLoss}; use crate::format::{ - AudioFormat, Av1Format, BackendConfig, FormatError, FrameTiming, H264Format, H264Framing, - H265Format, RendererRect, ScreenRect, VideoFormat, access_unit_to_avcc, + AudioFormat, Av1Format, BackendConfig, EmbeddedBackendConfig, FormatError, FrameTiming, + H264Format, H264Framing, H265Format, RendererRect, ScreenRect, VideoFormat, + access_unit_to_avcc, }; use crate::lifecycle::{BackendState, Lifecycle}; use crate::queue::{BoundedQueue, PushResult}; use self::audio::AudioPipeline; +pub use self::embedded::{ + AdoptedMetalContext, EmbeddedFrameProducer, MetalFrame, MetalRecordedFrame, +}; +use self::mailbox::LatestMailbox; use self::presentation::PresenterHandle; use self::surface::SurfaceOwner; -use self::video::{DecodedFrame, VideoDecoder}; +use self::video::{DecodedFrameOutput, VideoDecoder}; const MAX_OPUS_PACKET_BYTES: usize = 1_275; @@ -371,7 +378,7 @@ impl StreamSink { } let replacement = VideoDecoder::new( &format, - self.shared.video_queue.clone(), + self.shared.video_output.clone(), Arc::clone(&self.shared.counters), Arc::clone(&self.shared.failures), self.shared.video_frames_in_flight, @@ -387,7 +394,7 @@ impl StreamSink { } let previous = decoder.replace(replacement); drop(previous); - let discarded = self.shared.video_queue.clear(); + let discarded = self.shared.video_output.clear(); self.shared .counters .video_frames_dropped @@ -444,7 +451,7 @@ struct Shared { paused: AtomicBool, counters: Arc, failures: Arc, - video_queue: Arc>, + video_output: DecodedFrameOutput, video: Mutex>, audio: Mutex>, presenter: Mutex>, @@ -465,6 +472,10 @@ impl Shared { .unwrap_or_else(|poisoned| poisoned.into_inner()) .take(); drop(decoder); + let discarded = self.video_output.clear(); + self.counters + .video_frames_dropped + .fetch_add(discarded as u64, Ordering::Relaxed); if let Some(audio) = self .audio .lock() @@ -489,6 +500,7 @@ impl Shared { pub struct MacOsBackend { shared: Arc, surface: Option, + frame_producer: Option, _main_thread_only: PhantomData>, } @@ -500,6 +512,7 @@ impl MacOsBackend { let counters = Arc::new(Counters::default()); let failures = Arc::new(FailureReporter::default()); let video_queue = Arc::new(BoundedQueue::new(config.queues.decoded_video_frames)); + let video_output = DecodedFrameOutput::PresentationQueue(Arc::clone(&video_queue)); let presenter = PresenterHandle::start( surface.metal_layer(), surface.presentation_visibility(), @@ -509,7 +522,7 @@ impl MacOsBackend { )?; let video = VideoDecoder::new( &config.video, - Arc::clone(&video_queue), + video_output.clone(), Arc::clone(&counters), Arc::clone(&failures), config.queues.video_frames_in_flight, @@ -526,7 +539,7 @@ impl MacOsBackend { paused: AtomicBool::new(false), counters, failures, - video_queue, + video_output, video: Mutex::new(Some(video)), audio: Mutex::new(Some(audio)), presenter: Mutex::new(Some(presenter)), @@ -538,6 +551,94 @@ impl MacOsBackend { Ok(Self { shared, surface: Some(surface), + frame_producer: None, + _main_thread_only: PhantomData, + }) + } + + /// Starts VideoToolbox and CoreAudio for a shell-owned Qt/Metal renderer. + /// + /// This path creates no AppKit object, SDL window, `CAMetalLayer`, `CVDisplayLink`, Metal + /// device, or Metal command queue. Decoded IOSurface-backed frames are retained in a + /// latest-frame mailbox until [`EmbeddedFrameProducer::acquire_latest`] transfers them to Qt's + /// render thread. + pub fn start_embedded(config: EmbeddedBackendConfig) -> Result { + Self::start_embedded_inner(config, None:: bool>) + } + + /// Starts embedded output and forwards each newest retained frame to a shell mailbox. + /// + /// The publisher runs on VideoToolbox's callback thread and must return promptly. Returning + /// `true` indicates that the shell replaced an older unconsumed frame. + pub fn start_embedded_with_publisher( + config: EmbeddedBackendConfig, + publish: impl Fn(MetalFrame) -> bool + Send + Sync + 'static, + ) -> Result { + Self::start_embedded_inner(config, Some(publish)) + } + + fn start_embedded_inner( + config: EmbeddedBackendConfig, + publish: Option bool + Send + Sync + 'static>, + ) -> Result { + config.validate()?; + let counters = Arc::new(Counters::default()); + let failures = Arc::new(FailureReporter::default()); + let mailbox = Arc::new(LatestMailbox::new()); + let frame_producer = EmbeddedFrameProducer::new(mailbox, Arc::clone(&counters)); + let frame_available = publish.map(|publish| { + let producer = frame_producer.clone(); + Arc::new(move || { + if let Some(frame) = producer.acquire_latest() { + let replaced = publish(frame); + if replaced { + let counters = producer.counters(); + counters + .video_decoded_queue_dropped + .fetch_add(1, Ordering::Relaxed); + counters + .video_frames_dropped + .fetch_add(1, Ordering::Relaxed); + } + } + }) as Arc + }); + let video_output = DecodedFrameOutput::EmbeddedMailbox { + mailbox: Arc::clone(frame_producer.mailbox()), + frame_available, + }; + let video = VideoDecoder::new( + &config.video, + video_output.clone(), + Arc::clone(&counters), + Arc::clone(&failures), + config.queues.video_frames_in_flight, + )?; + let audio = AudioPipeline::start( + config.audio, + config.queues.opus_packets, + config.queues.pcm_milliseconds, + Arc::clone(&counters), + Arc::clone(&failures), + )?; + let shared = Arc::new(Shared { + lifecycle: Lifecycle::running(), + paused: AtomicBool::new(false), + counters, + failures, + video_output, + video: Mutex::new(Some(video)), + audio: Mutex::new(Some(audio)), + presenter: Mutex::new(None), + video_frames_in_flight: config.queues.video_frames_in_flight, + opus_packets: config.queues.opus_packets, + pcm_milliseconds: config.queues.pcm_milliseconds, + max_video_access_unit_bytes: config.queues.max_video_access_unit_bytes, + }); + Ok(Self { + shared, + surface: None, + frame_producer: Some(frame_producer), _main_thread_only: PhantomData, }) } @@ -552,6 +653,10 @@ impl MacOsBackend { self.surface.as_ref().map(SurfaceOwner::native_handle) } + pub fn frame_producer(&self) -> Option { + self.frame_producer.clone() + } + /// Updates a supplied-window child surface in renderer-relative, top-left AppKit points. /// /// This method is available only for `SurfaceTarget::NsWindow` and returns @@ -613,14 +718,16 @@ impl MacOsBackend { } pub fn set_paused(&mut self, paused: bool) -> Result<(), BackendError> { - let _main_thread = MainThreadMarker::new().ok_or(BackendError::MainThreadRequired)?; + if self.surface.is_some() && MainThreadMarker::new().is_none() { + return Err(BackendError::MainThreadRequired); + } if self.shared.lifecycle.state() != BackendState::Running { return Err(BackendError::Stopped); } if paused { self.shared.paused.store(true, Ordering::Release); } - let discarded = self.shared.video_queue.clear(); + let discarded = self.shared.video_output.clear(); self.shared .counters .video_frames_dropped diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/video.rs b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/video.rs index 55566e896..1fc44f0d5 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/video.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/video.rs @@ -27,16 +27,57 @@ use crate::failure::FailureReporter; use crate::format::{Av1Format, FrameTiming, H264Format, H265Format, VideoColorSpace, VideoFormat}; use crate::queue::{BoundedQueue, PushResult}; +use super::mailbox::LatestMailbox; use super::{BackendError, Counters}; +#[derive(Clone)] pub(super) struct DecodedFrame { pub(super) image: CFRetained, pub(super) color_space: VideoColorSpace, pub(super) minimum_frame_duration_seconds: f64, + pub(super) timestamp_100ns: i64, } // The callback retains the CVImageBuffer and no code mutates it after publication to the queue. unsafe impl Send for DecodedFrame {} +unsafe impl Sync for DecodedFrame {} + +#[derive(Clone)] +pub(super) enum DecodedFrameOutput { + PresentationQueue(Arc>), + EmbeddedMailbox { + mailbox: Arc>, + frame_available: Option>, + }, +} + +impl DecodedFrameOutput { + fn publish(&self, frame: DecodedFrame) -> bool { + match self { + Self::PresentationQueue(queue) => matches!( + queue.push_drop_oldest(frame), + PushResult::Replaced(_) | PushResult::Closed(_) + ), + Self::EmbeddedMailbox { + mailbox, + frame_available, + } => { + let replaced = mailbox.replace(frame); + if let Some(frame_available) = frame_available { + frame_available(); + } + replaced + } + } + } + + pub(super) fn clear(&self) -> usize { + match self { + Self::PresentationQueue(queue) => queue.clear(), + Self::EmbeddedMailbox { mailbox, .. } => usize::from(mailbox.clear()), + } + } +} struct InFlight { count: AtomicUsize, @@ -59,7 +100,7 @@ impl InFlight { } struct CallbackContext { - queue: Arc>, + output: DecodedFrameOutput, counters: Arc, failures: Arc, in_flight: Arc, @@ -80,7 +121,7 @@ unsafe impl Send for VideoDecoder {} impl VideoDecoder { pub(super) fn new( format: &VideoFormat, - queue: Arc>, + output: DecodedFrameOutput, counters: Arc, failures: Arc, maximum_in_flight: usize, @@ -91,7 +132,7 @@ impl VideoDecoder { maximum: maximum_in_flight, }); let mut callback_context = Box::new(CallbackContext { - queue, + output, counters, failures, in_flight: Arc::clone(&in_flight), @@ -120,14 +161,15 @@ impl VideoDecoder { let io_surface_properties_key = unsafe { kCVPixelBufferIOSurfacePropertiesKey }; let hardware_decoder_key = unsafe { kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder }; - let destination_attributes = make_dictionary(&[ - (cf_ptr(pixel_format_key), cf_ptr(&*pixel_format_number)), + let mut destination_entries = vec![ (cf_ptr(metal_compatibility_key), cf_ptr(true_value)), ( cf_ptr(io_surface_properties_key), cf_ptr(&*empty_properties), ), - ])?; + ]; + destination_entries.insert(0, (cf_ptr(pixel_format_key), cf_ptr(&*pixel_format_number))); + let destination_attributes = make_dictionary(&destination_entries)?; let decoder_specification = make_dictionary(&[(cf_ptr(hardware_decoder_key), cf_ptr(true_value))])?; @@ -271,7 +313,7 @@ unsafe extern "C-unwind" fn decompression_callback( status: i32, _info_flags: VTDecodeInfoFlags, image_buffer: *mut CVImageBuffer, - _presentation_time_stamp: CMTime, + presentation_time_stamp: CMTime, presentation_duration: CMTime, ) { let Some(context) = NonNull::new(output_refcon.cast::()) else { @@ -285,16 +327,14 @@ unsafe extern "C-unwind" fn decompression_callback( image, color_space: context.color_space, minimum_frame_duration_seconds: frame_duration_seconds(presentation_duration), + timestamp_100ns: time_to_100ns(presentation_time_stamp), }; context .counters .video_decoded .fetch_add(1, Ordering::Relaxed); context.failures.video_decode_succeeded(); - if matches!( - context.queue.push_drop_oldest(frame), - PushResult::Replaced(_) | PushResult::Closed(_) - ) { + if context.output.publish(frame) { context .counters .video_frames_dropped @@ -329,9 +369,26 @@ fn frame_duration_seconds(duration: CMTime) -> f64 { } } +fn time_to_100ns(time: CMTime) -> i64 { + if time.timescale <= 0 { + return 0; + } + i128::from(time.value) + .saturating_mul(10_000_000) + .checked_div(i128::from(time.timescale)) + .and_then(|value| i64::try_from(value).ok()) + .unwrap_or_else(|| { + if time.value.is_negative() { + i64::MIN + } else { + i64::MAX + } + }) +} + #[cfg(test)] mod tests { - use super::frame_duration_seconds; + use super::{frame_duration_seconds, time_to_100ns}; use objc2_core_media::{CMTime, CMTimeFlags}; #[test] @@ -344,6 +401,17 @@ mod tests { }; assert!((frame_duration_seconds(duration) - 1.0 / 120.0).abs() < f64::EPSILON); } + + #[test] + fn converts_core_media_time_to_cross_platform_100ns_units() { + let time = CMTime { + value: 90_000, + timescale: 90_000, + flags: CMTimeFlags(1), + epoch: 0, + }; + assert_eq!(time_to_100ns(time), 10_000_000); + } } fn create_format_description( diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/lib.rs index 2281fc953..3aff6f5f5 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/lib.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/lib.rs @@ -6,6 +6,11 @@ mod queue; #[cfg(windows)] mod windows; +#[cfg(windows)] +pub use windows::{ + AdoptedD3d11Context, D3d11Frame, D3d11FrameProducer, D3d11FrameSubmitter, D3d11RecordedFrame, +}; + use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::thread::JoinHandle; diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/decoder.rs b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/decoder.rs index c163c8c03..fca183c15 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/decoder.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/decoder.rs @@ -33,7 +33,11 @@ use crate::{ VideoCodec, VideoFormat, VideoPixelFormat, WindowsDecoderMode, }; -use super::graphics::Graphics; +pub(super) trait DecoderDevice { + fn device_manager(&self) -> &::windows::Win32::Media::MediaFoundation::IMFDXGIDeviceManager; + fn adapter_luid(&self) -> Result; + fn video_format(&self) -> VideoFormat; +} pub(super) struct DecodedVideoFrame { pub(super) texture: ID3D11Texture2D, @@ -56,8 +60,8 @@ pub(super) struct Decoder { } impl Decoder { - pub(super) fn probe( - graphics: &Graphics, + pub(super) fn probe( + graphics: &G, codec: VideoCodec, mode: WindowsDecoderMode, ) -> Result<(), String> { @@ -73,8 +77,8 @@ impl Decoder { Ok(()) } - pub(super) fn new( - graphics: &Graphics, + pub(super) fn new( + graphics: &G, format: VideoFormat, mode: WindowsDecoderMode, ) -> Result { @@ -360,22 +364,21 @@ impl Drop for Decoder { } } -fn configure_transform( +type ConfiguredTransform = ( + IMFTransform, + Option, + u32, + u32, + bool, + VideoPixelFormat, + bool, +); + +fn configure_transform( activation: &IMFActivate, - graphics: &Graphics, + graphics: &G, format: VideoFormat, -) -> Result< - ( - IMFTransform, - Option, - u32, - u32, - bool, - VideoPixelFormat, - bool, - ), - String, -> { +) -> Result { unsafe { let transform: IMFTransform = activation .ActivateObject() diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/embedded.rs b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/embedded.rs new file mode 100644 index 000000000..2b9ef2177 --- /dev/null +++ b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/embedded.rs @@ -0,0 +1,934 @@ +use std::collections::{HashMap, VecDeque}; +use std::ffi::c_void; +use std::mem::ManuallyDrop; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, ThreadId}; + +use ::windows::Win32::Foundation::{LUID, RECT}; +use ::windows::Win32::Graphics::Direct3D11::{ + D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_TEX2D_VPIV, D3D11_TEX2D_VPOV, + D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, + D3D11_VIDEO_PROCESSOR_CONTENT_DESC, D3D11_VIDEO_PROCESSOR_FORMAT_SUPPORT_OUTPUT, + D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC_0, + D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC_0, + D3D11_VIDEO_PROCESSOR_STREAM, D3D11_VIDEO_USAGE_OPTIMAL_SPEED, D3D11_VPIV_DIMENSION_TEXTURE2D, + D3D11_VPOV_DIMENSION_TEXTURE2D, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D, + ID3D11VideoContext, ID3D11VideoContext1, ID3D11VideoDevice, ID3D11VideoProcessor, + ID3D11VideoProcessorEnumerator, ID3D11VideoProcessorEnumerator1, ID3D11VideoProcessorInputView, + ID3D11VideoProcessorOutputView, +}; +use ::windows::Win32::Graphics::Dxgi::Common::{ + DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709, + DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT, DXGI_FORMAT_AYUV, DXGI_FORMAT_NV12, + DXGI_FORMAT_P010, DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_Y410, DXGI_RATIONAL, + DXGI_SAMPLE_DESC, +}; +use ::windows::Win32::Graphics::Dxgi::IDXGIDevice; +use ::windows::Win32::Media::MediaFoundation::{ + IMFDXGIDeviceManager, MF_VERSION, MFCreateDXGIDeviceManager, MFSTARTUP_LITE, MFShutdown, + MFStartup, +}; +use ::windows::Win32::System::Com::{ + CO_MTA_USAGE_COOKIE, CoDecrementMTAUsage, CoIncrementMTAUsage, +}; +use ::windows::core::{IUnknown, Interface}; + +use crate::queue::BoundedQueue; +use crate::{ + ADAPTIVE_VIDEO_QUEUE_CAPACITY, BackendError, BackendEvent, EncodedVideoFrame, PushOutcome, + Subsystem, VideoChromaFormat, VideoFormat, VideoPixelFormat, WindowsDecoderMode, +}; + +use super::decoder::{DecodedVideoFrame, Decoder, DecoderDevice}; + +const MAX_DECODE_STEPS_PER_RECORD: usize = ADAPTIVE_VIDEO_QUEUE_CAPACITY * 2; +const MAX_FRAME_SLOTS: usize = 8; + +/// Borrowed Qt D3D11 objects used by the embedded frame producer. +/// +/// Both pointers must identify the D3D11 device and its immediate context from +/// Qt's adopted QRhi. The producer takes its own COM references and never +/// assumes ownership of either incoming reference. +#[derive(Debug, Clone, Copy)] +pub struct AdoptedD3d11Context { + pub device: *mut c_void, + pub immediate_context: *mut c_void, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct D3d11RecordedFrame { + pub texture: *mut c_void, + pub width: u32, + pub height: u32, + pub frame_slot: u32, + pub generation: u64, + pub presentation_time_ns: u64, +} + +#[derive(Clone)] +pub struct D3d11FrameSubmitter { + encoded: Arc>, + events: Arc>, +} + +impl D3d11FrameSubmitter { + pub fn submit_video(&self, frame: EncodedVideoFrame) -> Result { + frame.validate()?; + let outcome = self + .encoded + .push_or_clear_on_overflow(frame) + .map_err(|_| BackendError::WorkerDisconnected)?; + if outcome == PushOutcome::DroppedOldest { + let _ = self + .events + .push(BackendEvent::QueueOverflow(Subsystem::VideoDecode)); + let _ = self.events.push(BackendEvent::KeyFrameRequired); + } + Ok(outcome) + } +} + +struct EmbeddedMediaRuntime { + mta_cookie: usize, + media_foundation_started: bool, +} + +impl EmbeddedMediaRuntime { + fn initialize() -> Result { + unsafe { + let mta_cookie = + CoIncrementMTAUsage().map_err(|error| format!("CoIncrementMTAUsage: {error}"))?; + if let Err(error) = MFStartup(MF_VERSION, MFSTARTUP_LITE) { + let _ = CoDecrementMTAUsage(mta_cookie); + return Err(format!("MFStartup: {error}")); + } + Ok(Self { + mta_cookie: mta_cookie.0 as usize, + media_foundation_started: true, + }) + } + } +} + +impl Drop for EmbeddedMediaRuntime { + fn drop(&mut self) { + unsafe { + if self.media_foundation_started { + let _ = MFShutdown(); + } + let _ = CoDecrementMTAUsage(CO_MTA_USAGE_COOKIE(self.mta_cookie as *mut c_void)); + } + } +} + +struct FrameSlot { + texture: ID3D11Texture2D, + output_view: ID3D11VideoProcessorOutputView, +} + +struct ProcessorResources { + input_width: u32, + input_height: u32, + input_format: DXGI_FORMAT, + output_width: u32, + output_height: u32, + enumerator: ID3D11VideoProcessorEnumerator, + processor: ID3D11VideoProcessor, + input_views: HashMap<(usize, u32), ID3D11VideoProcessorInputView>, + slots: Vec, +} + +struct AdoptedResources { + device: ID3D11Device, + video_device: ID3D11VideoDevice, + video_context: ID3D11VideoContext, + video_context_1: Option, + manager: IMFDXGIDeviceManager, + format: VideoFormat, + generation: u64, + processor: Option, +} + +impl AdoptedResources { + unsafe fn new(adopted: AdoptedD3d11Context, format: VideoFormat) -> Result { + if adopted.device.is_null() || adopted.immediate_context.is_null() { + return Err("Qt supplied a null D3D11 device or immediate context".to_owned()); + } + let device = unsafe { clone_interface::(adopted.device)? }; + let context = unsafe { clone_interface::(adopted.immediate_context)? }; + let context_device = unsafe { + context + .GetDevice() + .map_err(|error| format!("ID3D11DeviceContext::GetDevice: {error}"))? + }; + if com_identity(&device)? != com_identity(&context_device)? { + return Err( + "Qt D3D11 immediate context does not belong to the adopted device".to_owned(), + ); + } + let video_device = device + .cast() + .map_err(|error| format!("Qt D3D11 device has no video interface: {error}"))?; + let video_context: ID3D11VideoContext = context + .cast() + .map_err(|error| format!("Qt immediate context has no video interface: {error}"))?; + let video_context_1 = video_context.cast().ok(); + let mut reset_token = 0; + let mut manager = None; + unsafe { + MFCreateDXGIDeviceManager(&mut reset_token, &mut manager) + .map_err(|error| format!("MFCreateDXGIDeviceManager: {error}"))?; + } + let manager = manager.ok_or("MFCreateDXGIDeviceManager returned no manager")?; + unsafe { + manager + .ResetDevice(&device, reset_token) + .map_err(|error| format!("IMFDXGIDeviceManager::ResetDevice: {error}"))?; + } + Ok(Self { + device, + video_device, + video_context, + video_context_1, + manager, + format, + generation: 0, + processor: None, + }) + } + + fn reconfigure(&mut self, format: VideoFormat) { + if self.format != format { + self.format = format; + self.processor = None; + } + } + + fn reset_decoder_views(&mut self) { + if let Some(processor) = self.processor.as_mut() { + processor.input_views.clear(); + } + } + + unsafe fn validate_adopted_context(&self, adopted: AdoptedD3d11Context) -> Result<(), String> { + let device = unsafe { clone_interface::(adopted.device)? }; + let context = unsafe { clone_interface::(adopted.immediate_context)? }; + if com_identity(&device)? != com_identity(&self.device)? + || com_identity(&context)? != com_identity(&self.video_context)? + { + return Err("D3D11 frame belongs to an older Qt graphics context".to_owned()); + } + Ok(()) + } + + fn record( + &mut self, + frame_slot: u32, + frame: &DecodedVideoFrame, + ) -> Result { + let slot = usize::try_from(frame_slot) + .ok() + .filter(|slot| *slot < MAX_FRAME_SLOTS) + .ok_or_else(|| format!("invalid D3D11 frame slot {frame_slot}"))?; + let mut input_description = D3D11_TEXTURE2D_DESC::default(); + unsafe { + frame.texture.GetDesc(&mut input_description); + } + let pixel_format = pixel_format_from_dxgi(input_description.Format).ok_or_else(|| { + format!( + "Media Foundation returned unsupported D3D11 texture format {}", + input_description.Format.0 + ) + })?; + if pixel_format != self.format.pixel_format + || input_description.Width != self.format.width + || input_description.Height != self.format.height + { + self.format = VideoFormat { + width: input_description.Width, + height: input_description.Height, + pixel_format, + chroma_format: chroma_format(pixel_format), + ..self.format + }; + self.processor = None; + } + let array_slice = decoder_array_slice( + frame.subresource, + input_description.MipLevels, + input_description.ArraySize, + )?; + self.ensure_processor( + input_description.Width, + input_description.Height, + input_description.Format, + input_description.Width, + input_description.Height, + )?; + let processor = self + .processor + .as_mut() + .ok_or("embedded video processor is unavailable")?; + let input_key = (frame.texture.as_raw() as usize, array_slice); + let input_view = if let Some(view) = processor.input_views.get(&input_key) { + view.clone() + } else { + let description = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC { + FourCC: 0, + ViewDimension: D3D11_VPIV_DIMENSION_TEXTURE2D, + Anonymous: D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC_0 { + Texture2D: D3D11_TEX2D_VPIV { + MipSlice: 0, + ArraySlice: array_slice, + }, + }, + }; + let mut view = None; + unsafe { + self.video_device + .CreateVideoProcessorInputView( + &frame.texture, + &processor.enumerator, + &description, + Some(&mut view), + ) + .map_err(|error| format!("CreateVideoProcessorInputView: {error}"))?; + } + let view = view.ok_or("D3D11 returned no video processor input view")?; + processor.input_views.insert(input_key, view.clone()); + view + }; + let source = RECT { + left: 0, + top: 0, + right: processor.input_width as i32, + bottom: processor.input_height as i32, + }; + let destination = RECT { + left: 0, + top: 0, + right: processor.output_width as i32, + bottom: processor.output_height as i32, + }; + let output_view = processor.slots[slot].output_view.clone(); + unsafe { + self.video_context.VideoProcessorSetStreamFrameFormat( + &processor.processor, + 0, + D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, + ); + self.video_context.VideoProcessorSetStreamSourceRect( + &processor.processor, + 0, + true, + Some(&source), + ); + self.video_context.VideoProcessorSetStreamDestRect( + &processor.processor, + 0, + true, + Some(&destination), + ); + self.video_context.VideoProcessorSetOutputTargetRect( + &processor.processor, + true, + Some(&RECT { + left: 0, + top: 0, + right: processor.output_width as i32, + bottom: processor.output_height as i32, + }), + ); + let mut stream = D3D11_VIDEO_PROCESSOR_STREAM { + Enable: true.into(), + pInputSurface: ManuallyDrop::new(Some(input_view)), + ..Default::default() + }; + let result = self.video_context.VideoProcessorBlt( + &processor.processor, + &output_view, + 0, + std::slice::from_ref(&stream), + ); + ManuallyDrop::drop(&mut stream.pInputSurface); + result.map_err(|error| format!("VideoProcessorBlt: {error}"))?; + } + self.generation = self.generation.wrapping_add(1).max(1); + Ok(D3d11RecordedFrame { + texture: processor.slots[slot].texture.as_raw(), + width: processor.output_width, + height: processor.output_height, + frame_slot, + generation: self.generation, + presentation_time_ns: u64::try_from(frame.timestamp_100ns.max(0)) + .unwrap_or(0) + .saturating_mul(100), + }) + } + + fn ensure_processor( + &mut self, + input_width: u32, + input_height: u32, + input_format: DXGI_FORMAT, + output_width: u32, + output_height: u32, + ) -> Result<(), String> { + if self.processor.as_ref().is_some_and(|processor| { + processor.input_width == input_width + && processor.input_height == input_height + && processor.input_format == input_format + && processor.output_width == output_width + && processor.output_height == output_height + }) { + return Ok(()); + } + let description = D3D11_VIDEO_PROCESSOR_CONTENT_DESC { + InputFrameFormat: D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, + InputFrameRate: DXGI_RATIONAL { + Numerator: self.format.frame_rate_numerator.get(), + Denominator: self.format.frame_rate_denominator.get(), + }, + InputWidth: input_width, + InputHeight: input_height, + OutputFrameRate: DXGI_RATIONAL { + Numerator: self.format.frame_rate_numerator.get(), + Denominator: self.format.frame_rate_denominator.get(), + }, + OutputWidth: output_width, + OutputHeight: output_height, + Usage: D3D11_VIDEO_USAGE_OPTIMAL_SPEED, + }; + let enumerator = unsafe { + self.video_device + .CreateVideoProcessorEnumerator(&description) + .map_err(|error| format!("CreateVideoProcessorEnumerator: {error}"))? + }; + validate_conversion(&enumerator, input_format, self.format)?; + let processor = unsafe { + self.video_device + .CreateVideoProcessor(&enumerator, 0) + .map_err(|error| format!("CreateVideoProcessor: {error}"))? + }; + if let Some(context) = self.video_context_1.as_ref() { + unsafe { + context.VideoProcessorSetStreamColorSpace1( + &processor, + 0, + input_color_space(self.format), + ); + context.VideoProcessorSetOutputColorSpace1( + &processor, + DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, + ); + } + } + let mut slots = Vec::with_capacity(MAX_FRAME_SLOTS); + for _ in 0..MAX_FRAME_SLOTS { + let description = frame_slot_description(output_width, output_height); + let mut texture = None; + unsafe { + self.device + .CreateTexture2D(&description, None, Some(&mut texture)) + .map_err(|error| format!("CreateTexture2D frame slot: {error}"))?; + } + let texture = texture.ok_or("D3D11 returned no frame-slot texture")?; + let output_description = D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC { + ViewDimension: D3D11_VPOV_DIMENSION_TEXTURE2D, + Anonymous: D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC_0 { + Texture2D: D3D11_TEX2D_VPOV { MipSlice: 0 }, + }, + }; + let mut output_view = None; + unsafe { + self.video_device + .CreateVideoProcessorOutputView( + &texture, + &enumerator, + &output_description, + Some(&mut output_view), + ) + .map_err(|error| format!("CreateVideoProcessorOutputView: {error}"))?; + } + slots.push(FrameSlot { + texture, + output_view: output_view.ok_or("D3D11 returned no frame-slot output view")?, + }); + } + self.processor = Some(ProcessorResources { + input_width, + input_height, + input_format, + output_width, + output_height, + enumerator, + processor, + input_views: HashMap::new(), + slots, + }); + Ok(()) + } +} + +impl DecoderDevice for AdoptedResources { + fn device_manager(&self) -> &IMFDXGIDeviceManager { + &self.manager + } + + fn adapter_luid(&self) -> Result { + unsafe { + let dxgi_device: IDXGIDevice = self + .device + .cast() + .map_err(|error| format!("Qt D3D11 DXGI device: {error}"))?; + let adapter = dxgi_device + .GetAdapter() + .map_err(|error| format!("Qt D3D11 adapter: {error}"))?; + Ok(adapter + .GetDesc() + .map_err(|error| format!("Qt D3D11 adapter description: {error}"))? + .AdapterLuid) + } + } + + fn video_format(&self) -> VideoFormat { + self.format + } +} + +struct D3d11Pipeline { + owner_thread: ThreadId, + mode: WindowsDecoderMode, + resources: AdoptedResources, + decoder: Option, + decoded: VecDeque, + encoded: Arc>, + events: Arc>, + _runtime: EmbeddedMediaRuntime, +} + +unsafe impl Send for D3d11Pipeline {} + +pub struct D3d11Frame { + frame: DecodedVideoFrame, + state: Arc>, + sequence: u64, +} + +unsafe impl Send for D3d11Frame {} +unsafe impl Sync for D3d11Frame {} + +impl D3d11Frame { + pub fn width(&self) -> u32 { + let mut description = D3D11_TEXTURE2D_DESC::default(); + unsafe { + self.frame.texture.GetDesc(&mut description); + } + description.Width + } + + pub fn height(&self) -> u32 { + let mut description = D3D11_TEXTURE2D_DESC::default(); + unsafe { + self.frame.texture.GetDesc(&mut description); + } + description.Height + } + + pub const fn sequence(&self) -> u64 { + self.sequence + } + + pub fn presentation_time_ns(&self) -> u64 { + u64::try_from(self.frame.timestamp_100ns.max(0)) + .unwrap_or(0) + .saturating_mul(100) + } + + /// Converts this decoded surface into Qt's current RGBA8 frame-slot target. + /// + /// # Safety + /// + /// `target.texture` must identify a live `ID3D11Texture2D` from the adopted + /// Qt device. Recording must run on the render thread that created the + /// producer. + pub unsafe fn record( + &self, + adopted: AdoptedD3d11Context, + frame_slot: u32, + ) -> Result { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.ensure_render_thread()?; + unsafe { state.resources.validate_adopted_context(adopted) }.map_err(|message| { + BackendError::DeviceLost { + subsystem: Subsystem::VideoPresentation, + message, + } + })?; + state + .resources + .record(frame_slot, &self.frame) + .map_err(|message| BackendError::DeviceLost { + subsystem: Subsystem::VideoPresentation, + message, + }) + } +} + +#[derive(Clone)] +pub struct D3d11FrameProducer { + state: Arc>, + sequence: Arc, +} + +impl D3d11FrameProducer { + /// Adopts Qt's D3D11 device and immediate context without creating a + /// window, swap chain, SDL video subsystem, or presentation path. + /// + /// # Safety + /// + /// Both COM pointers must be valid for this call and identify live + /// `ID3D11Device` and `ID3D11DeviceContext` interfaces. The context must be + /// Qt's immediate context, and all producer methods except submission and + /// event polling must remain on the creating render thread. + pub unsafe fn new( + adopted: AdoptedD3d11Context, + format: VideoFormat, + decoder_mode: WindowsDecoderMode, + ) -> Result<(Self, D3d11FrameSubmitter), BackendError> { + format.validate()?; + let runtime = EmbeddedMediaRuntime::initialize().map_err(BackendError::Startup)?; + let resources = + unsafe { AdoptedResources::new(adopted, format) }.map_err(BackendError::Startup)?; + let decoder = Decoder::new(&resources, format, decoder_mode) + .map_err(|error| BackendError::Startup(format!("Media Foundation decoder: {error}")))?; + let encoded = Arc::new(BoundedQueue::new(ADAPTIVE_VIDEO_QUEUE_CAPACITY)); + let events = Arc::new(BoundedQueue::new(64)); + let submitter = D3d11FrameSubmitter { + encoded: Arc::clone(&encoded), + events: Arc::clone(&events), + }; + let state = Arc::new(Mutex::new(D3d11Pipeline { + owner_thread: thread::current().id(), + mode: decoder_mode, + resources, + decoder: Some(decoder), + decoded: VecDeque::with_capacity(ADAPTIVE_VIDEO_QUEUE_CAPACITY), + encoded, + events, + _runtime: runtime, + })); + Ok(( + Self { + state, + sequence: Arc::new(AtomicU64::new(0)), + }, + submitter, + )) + } + + pub fn acquire_latest(&self) -> Result, BackendError> { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let frame = state.acquire_latest()?; + Ok(frame.map(|frame| D3d11Frame { + frame, + state: Arc::clone(&self.state), + sequence: self.sequence.fetch_add(1, Ordering::AcqRel) + 1, + })) + } + + pub fn try_event(&self) -> Option { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .events + .try_pop() + } +} + +impl D3d11Pipeline { + fn acquire_latest(&mut self) -> Result, BackendError> { + self.ensure_render_thread()?; + for _ in 0..MAX_DECODE_STEPS_PER_RECORD { + let decoder = self + .decoder + .as_mut() + .ok_or_else(|| BackendError::DeviceLost { + subsystem: Subsystem::VideoDecode, + message: "embedded decoder is unavailable".to_owned(), + })?; + let produced = decoder + .poll_output(&mut self.decoded, &self.events) + .map_err(|error| BackendError::DeviceLost { + subsystem: Subsystem::VideoDecode, + message: error, + })?; + let decoded_format = decoder.format(); + self.resources.reconfigure(decoded_format); + let mut submitted = false; + while self.decoder.as_ref().is_some_and(Decoder::wants_input) { + let Some(frame) = self.encoded.try_pop() else { + break; + }; + submitted = true; + if frame.reset_decoder { + self.reset_decoder() + .map_err(|message| BackendError::DeviceLost { + subsystem: Subsystem::VideoDecode, + message, + })?; + } + self.decoder + .as_mut() + .expect("decoder restored before submission") + .submit(frame) + .map_err(|message| BackendError::DeviceLost { + subsystem: Subsystem::VideoDecode, + message, + })?; + } + if produced == 0 && !submitted { + break; + } + } + let Some(frame) = self.decoded.pop_back() else { + return Ok(None); + }; + if !self.decoded.is_empty() { + self.decoded.clear(); + let _ = self + .events + .push(BackendEvent::QueueOverflow(Subsystem::VideoPresentation)); + } + Ok(Some(frame)) + } + + fn reset_decoder(&mut self) -> Result<(), String> { + if let Some(mut decoder) = self.decoder.take() { + decoder.stop(); + } + self.decoded.clear(); + self.resources.reset_decoder_views(); + self.decoder = Some(Decoder::new( + &self.resources, + self.resources.format, + self.mode, + )?); + Ok(()) + } + + fn ensure_render_thread(&self) -> Result<(), BackendError> { + if thread::current().id() == self.owner_thread { + Ok(()) + } else { + Err(BackendError::InvalidConfig( + "embedded D3D11 frame production must stay on Qt's render thread".to_owned(), + )) + } + } +} + +impl Drop for D3d11Pipeline { + fn drop(&mut self) { + self.encoded.close(); + if let Some(mut decoder) = self.decoder.take() { + decoder.stop(); + } + } +} + +unsafe fn clone_interface(pointer: *mut c_void) -> Result { + if pointer.is_null() { + return Err("cannot adopt a null COM interface".to_owned()); + } + let borrowed = ManuallyDrop::new(unsafe { T::from_raw(pointer) }); + Ok((*borrowed).clone()) +} + +fn com_identity(interface: &T) -> Result { + interface + .cast::() + .map(|unknown| unknown.as_raw() as usize) + .map_err(|error| error.to_string()) +} + +fn decoder_array_slice(subresource: u32, mip_levels: u32, array_size: u32) -> Result { + let mip_levels = mip_levels.max(1); + let array_size = array_size.max(1); + let total = mip_levels + .checked_mul(array_size) + .ok_or("decoder texture subresource count overflowed")?; + if subresource >= total { + return Err(format!( + "decoder subresource {subresource} exceeds texture layout ({mip_levels} mips, {array_size} slices)" + )); + } + Ok(subresource / mip_levels) +} + +fn frame_slot_description(width: u32, height: u32) -> D3D11_TEXTURE2D_DESC { + D3D11_TEXTURE2D_DESC { + Width: width, + Height: height, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET).0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + } +} + +fn validate_conversion( + enumerator: &ID3D11VideoProcessorEnumerator, + input_format: DXGI_FORMAT, + format: VideoFormat, +) -> Result<(), String> { + unsafe { + let support = enumerator + .CheckVideoProcessorFormat(DXGI_FORMAT_R8G8B8A8_UNORM) + .map_err(|error| format!("query RGBA8 video-processor output: {error}"))?; + if support & D3D11_VIDEO_PROCESSOR_FORMAT_SUPPORT_OUTPUT.0 as u32 == 0 { + return Err("D3D11 video processor does not support RGBA8 output".to_owned()); + } + if let Ok(enumerator_1) = enumerator.cast::() { + let supported = enumerator_1 + .CheckVideoProcessorFormatConversion( + input_format, + input_color_space(format), + DXGI_FORMAT_R8G8B8A8_UNORM, + DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, + ) + .map_err(|error| format!("query D3D11 embedded conversion: {error}"))?; + if !supported.as_bool() { + return Err(format!( + "D3D11 driver rejects embedded video conversion {} -> RGBA8", + input_format.0 + )); + } + } + } + Ok(()) +} + +fn input_color_space( + format: VideoFormat, +) -> ::windows::Win32::Graphics::Dxgi::Common::DXGI_COLOR_SPACE_TYPE { + if format.full_range { + DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709 + } else { + DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709 + } +} + +fn pixel_format_from_dxgi(format: DXGI_FORMAT) -> Option { + match format { + DXGI_FORMAT_NV12 => Some(VideoPixelFormat::Nv12), + DXGI_FORMAT_P010 => Some(VideoPixelFormat::P010), + DXGI_FORMAT_AYUV => Some(VideoPixelFormat::Ayuv), + DXGI_FORMAT_Y410 => Some(VideoPixelFormat::Y410), + _ => None, + } +} + +fn chroma_format(format: VideoPixelFormat) -> VideoChromaFormat { + match format { + VideoPixelFormat::Nv12 | VideoPixelFormat::P010 => VideoChromaFormat::Cs420, + VideoPixelFormat::Ayuv | VideoPixelFormat::Y410 => VideoChromaFormat::Cs444, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decoder_subresource_preserves_array_slice() { + assert_eq!(decoder_array_slice(0, 1, 8), Ok(0)); + assert_eq!(decoder_array_slice(5, 1, 8), Ok(5)); + assert_eq!(decoder_array_slice(7, 1, 8), Ok(7)); + assert_eq!(decoder_array_slice(6, 3, 4), Ok(2)); + assert!(decoder_array_slice(12, 3, 4).is_err()); + } + + #[test] + fn all_decoder_formats_map_to_video_processor_inputs() { + let cases = [ + ( + DXGI_FORMAT_NV12, + VideoPixelFormat::Nv12, + VideoChromaFormat::Cs420, + ), + ( + DXGI_FORMAT_P010, + VideoPixelFormat::P010, + VideoChromaFormat::Cs420, + ), + ( + DXGI_FORMAT_AYUV, + VideoPixelFormat::Ayuv, + VideoChromaFormat::Cs444, + ), + ( + DXGI_FORMAT_Y410, + VideoPixelFormat::Y410, + VideoChromaFormat::Cs444, + ), + ]; + for (dxgi, pixel, chroma) in cases { + let mapped = pixel_format_from_dxgi(dxgi).expect("supported decoder format"); + assert_eq!(mapped, pixel); + assert_eq!(chroma_format(mapped), chroma); + } + assert_eq!(DXGI_FORMAT_R8G8B8A8_UNORM.0, 28); + } + + #[test] + fn frame_slots_require_rgba8_shader_readable_render_targets() { + let description = frame_slot_description(1920, 1080); + assert_eq!(description.Width, 1920); + assert_eq!(description.Height, 1080); + assert_eq!(description.Format, DXGI_FORMAT_R8G8B8A8_UNORM); + assert_ne!( + description.BindFlags & D3D11_BIND_SHADER_RESOURCE.0 as u32, + 0 + ); + assert_ne!(description.BindFlags & D3D11_BIND_RENDER_TARGET.0 as u32, 0); + } + + #[test] + fn embedded_module_has_no_window_swapchain_sdl_or_present_path() { + let source = include_str!("embedded.rs"); + for forbidden in [ + concat!("Create", "Window"), + concat!("Create", "SwapChain"), + concat!(".Pre", "sent("), + concat!("sdl", "2::"), + ] { + assert!( + !source.contains(forbidden), + "embedded producer contains forbidden presentation token {forbidden}" + ); + } + } + + #[test] + fn encoded_submitter_is_safe_to_move_to_the_transport_thread() { + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + } +} diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/graphics.rs b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/graphics.rs index 214a25436..a2c65ca81 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/graphics.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/graphics.rs @@ -63,6 +63,8 @@ use crate::{ VideoPixelFormat, WindowHandle, WindowsGraphicsApi, }; +use super::decoder::DecoderDevice; + const WINDOW_CLASS: ::windows::core::PCWSTR = w!("OpenNOWStreamerD3D11Surface"); struct DeviceResources { @@ -173,7 +175,7 @@ impl DeviceResources { let mut context = None; D3D11On12CreateDevice( &d3d12_device, - (D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT).0 as u32, + (D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT).0, Some(&[D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0]), Some(&[Some(queue_unknown)]), 0, @@ -776,6 +778,20 @@ impl Graphics { } } +impl DecoderDevice for Graphics { + fn device_manager(&self) -> &IMFDXGIDeviceManager { + self.device_manager() + } + + fn adapter_luid(&self) -> Result { + self.adapter_luid() + } + + fn video_format(&self) -> VideoFormat { + self.video_format() + } +} + fn create_swap_chain( device: &ID3D11Device, hwnd: HWND, diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/mod.rs b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/mod.rs index a5e1b1efe..b1ff52928 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/mod.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/windows/mod.rs @@ -1,7 +1,12 @@ mod audio; mod decoder; +mod embedded; mod graphics; +pub use embedded::{ + AdoptedD3d11Context, D3d11Frame, D3d11FrameProducer, D3d11FrameSubmitter, D3d11RecordedFrame, +}; + use std::collections::VecDeque; use std::sync::{Arc, mpsc}; use std::thread::{self, JoinHandle}; diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/Cargo.toml b/native/opennow-streamer/crates/opennow-streamer-platform/Cargo.toml index 36520e300..037b9c59b 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform/Cargo.toml +++ b/native/opennow-streamer/crates/opennow-streamer-platform/Cargo.toml @@ -7,12 +7,12 @@ rust-version.workspace = true [features] default = [] +test-runtime = [] linux-vaapi = ["opennow-streamer-platform-linux/vaapi"] linux-ffmpeg = ["opennow-streamer-platform-linux/ffmpeg"] linux-ffmpeg-bundled = ["opennow-streamer-platform-linux/ffmpeg-bundled"] [dependencies] -audiopus_sys.workspace = true base64.workspace = true image.workspace = true openh264.workspace = true diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/embedded_input.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/embedded_input.rs new file mode 100644 index 000000000..6380f64db --- /dev/null +++ b/native/opennow-streamer/crates/opennow-streamer-platform/src/embedded_input.rs @@ -0,0 +1,132 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::{CapturedInput, CapturedInputQueue}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmbeddedLocalAction { + Guide, + Screenshot, + RecordingToggle, +} + +pub struct EmbeddedInputCapture { + queue: Arc, + active: AtomicBool, + #[cfg(target_os = "linux")] + raw: Mutex>, + #[cfg(target_os = "windows")] + raw: Mutex>, +} + +impl EmbeddedInputCapture { + pub fn new(queue: Arc) -> Self { + Self { + queue, + active: AtomicBool::new(false), + #[cfg(any(target_os = "linux", target_os = "windows"))] + raw: Mutex::new(None), + } + } + + pub fn submit(&self, input: CapturedInput) { + if self.active.load(Ordering::Acquire) { + self.queue.push(input); + } + } + + pub fn submit_local_action(&self, action: EmbeddedLocalAction) { + self.submit(match action { + EmbeddedLocalAction::Guide => CapturedInput::Guide, + EmbeddedLocalAction::Screenshot => CapturedInput::Screenshot, + EmbeddedLocalAction::RecordingToggle => CapturedInput::RecordingToggle, + }); + } + + pub fn set_active(&self, active: bool, relative_mouse: bool, window_handle: usize) -> bool { + if !active { + self.active.store(false, Ordering::Release); + } else { + self.active.store(true, Ordering::Release); + } + + #[cfg(target_os = "linux")] + { + let _ = window_handle; + let raw_enabled = active && relative_mouse; + let mut raw = self + .raw + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if raw_enabled && raw.is_none() { + match crate::linux_xinput::LinuxXInputController::start(Arc::clone(&self.queue)) { + Ok(controller) => *raw = Some(controller), + Err(error) => eprintln!("Embedded XInput2 capture unavailable: {error}"), + } + } + if let Some(raw) = raw.as_ref() { + raw.set_enabled(raw_enabled); + raw_enabled + } else { + false + } + } + + #[cfg(target_os = "windows")] + { + let mut raw = self + .raw + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if active && raw.is_none() && window_handle != 0 { + match crate::windows_raw_input::WindowsRawInputController::start( + window_handle as isize, + Arc::clone(&self.queue), + ) { + Ok(controller) => *raw = Some(controller), + Err(error) => eprintln!("Embedded Raw Input capture unavailable: {error}"), + } + } + if let Some(raw) = raw.as_ref() { + if window_handle != 0 { + raw.set_foreground_owner(window_handle as isize); + } + raw.set_capture(active, relative_mouse); + true + } else { + false + } + } + + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + let _ = (relative_mouse, window_handle); + false + } + } + + pub fn queue(&self) -> Arc { + Arc::clone(&self.queue) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inactive_capture_drops_input_and_active_capture_preserves_typed_events() { + let queue = Arc::new(CapturedInputQueue::default()); + let capture = EmbeddedInputCapture::new(Arc::clone(&queue)); + capture.submit(CapturedInput::Key { + virtual_key: 0x57, + modifiers: 0, + pressed: true, + }); + assert_eq!(queue.take(), None); + + capture.active.store(true, Ordering::Release); + capture.submit_local_action(EmbeddedLocalAction::Guide); + assert_eq!(queue.take(), Some(CapturedInput::Guide)); + } +} diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/graphics.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/graphics.rs new file mode 100644 index 000000000..7d06a94e5 --- /dev/null +++ b/native/opennow-streamer/crates/opennow-streamer-platform/src/graphics.rs @@ -0,0 +1,616 @@ +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, ThreadId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GraphicsApi { + D3d11, + Vulkan, + Metal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GraphicsContext { + pub api: GraphicsApi, + pub instance: usize, + pub physical_device: usize, + pub device: usize, + pub queue: usize, + pub queue_family_index: u32, +} + +impl GraphicsContext { + fn validate(self) -> Result { + if self.device == 0 || self.queue == 0 { + return Err(GraphicsRuntimeError::InvalidContext( + "the graphics device and queue must be non-null", + )); + } + if self.api == GraphicsApi::Vulkan && (self.instance == 0 || self.physical_device == 0) { + return Err(GraphicsRuntimeError::InvalidContext( + "Vulkan requires non-null instance and physical-device handles", + )); + } + Ok(self) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GraphicsRecordCommand { + pub command_buffer: usize, + pub frame_slot: u32, +} + +impl GraphicsRecordCommand { + fn validate(self) -> Result { + if self.command_buffer == 0 { + return Err(GraphicsRuntimeError::InvalidRenderCommand( + "the command buffer must be non-null", + )); + } + Ok(self) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GraphicsRecordedFrame { + pub resource: u64, + pub resource_view: u64, + pub width: u32, + pub height: u32, + pub frame_slot: u32, + pub generation: u64, + pub presentation_time_ns: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GraphicsFrameInfo { + pub width: u32, + pub height: u32, + pub sequence: u64, + pub presentation_time_ns: u64, +} + +pub trait GraphicsFrame: Send + Sync + 'static { + fn info(&self) -> GraphicsFrameInfo; + + fn record( + &self, + context: GraphicsContext, + command: GraphicsRecordCommand, + ) -> Result; +} + +#[cfg(target_os = "linux")] +impl GraphicsFrame for opennow_streamer_platform_linux::LinuxGpuFrame { + fn info(&self) -> GraphicsFrameInfo { + GraphicsFrameInfo { + width: self.width(), + height: self.height(), + sequence: self.sequence(), + presentation_time_ns: self.presentation_time_ns(), + } + } + + fn record( + &self, + context: GraphicsContext, + command: GraphicsRecordCommand, + ) -> Result { + if context.api != GraphicsApi::Vulkan { + return Err("a Linux decoded frame requires a Vulkan graphics context".to_owned()); + } + let frame = unsafe { + self.record( + opennow_streamer_platform_linux::VulkanRenderDevice { + instance: context.instance, + physical_device: context.physical_device, + device: context.device, + queue_family: context.queue_family_index, + }, + command.command_buffer, + command.frame_slot, + ) + } + .map_err(|error| error.to_string())?; + Ok(GraphicsRecordedFrame { + resource: frame.image, + resource_view: frame.image_view, + width: frame.width, + height: frame.height, + frame_slot: frame.slot, + generation: frame.generation, + presentation_time_ns: frame.timestamp_us.saturating_mul(1_000), + }) + } +} + +#[cfg(target_os = "macos")] +impl GraphicsFrame for opennow_streamer_platform_macos::MetalFrame { + fn info(&self) -> GraphicsFrameInfo { + GraphicsFrameInfo { + width: self.width(), + height: self.height(), + sequence: self.sequence(), + presentation_time_ns: self.presentation_time_ns(), + } + } + + fn record( + &self, + context: GraphicsContext, + command: GraphicsRecordCommand, + ) -> Result { + if context.api != GraphicsApi::Metal { + return Err("a macOS VideoToolbox frame requires a Metal graphics context".to_owned()); + } + let frame = unsafe { + self.record( + opennow_streamer_platform_macos::AdoptedMetalContext { + device: context.device as *mut std::ffi::c_void, + command_buffer: command.command_buffer as *mut std::ffi::c_void, + }, + command.frame_slot, + ) + } + .map_err(|error| error.to_string())?; + Ok(GraphicsRecordedFrame { + resource: frame.texture as usize as u64, + resource_view: 0, + width: frame.width, + height: frame.height, + frame_slot: frame.frame_slot, + generation: frame.generation, + presentation_time_ns: frame.presentation_time_ns, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GraphicsRuntimeError { + InvalidContext(&'static str), + InvalidRenderCommand(&'static str), + SceneGraphInactive, + WrongThread, + NoFrame, + StaleFrame, + FrameAlreadyRecorded, + RecordFailed(String), +} + +impl fmt::Display for GraphicsRuntimeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidContext(message) | Self::InvalidRenderCommand(message) => { + formatter.write_str(message) + } + Self::SceneGraphInactive => formatter.write_str("the scene graph is not initialized"), + Self::WrongThread => { + formatter.write_str("graphics access must stay on the render thread") + } + Self::NoFrame => formatter.write_str("no decoded GPU frame is pending"), + Self::StaleFrame => { + formatter.write_str("the GPU frame belongs to an older scene graph") + } + Self::FrameAlreadyRecorded => { + formatter.write_str("the GPU frame token was already recorded") + } + Self::RecordFailed(message) => { + write!(formatter, "GPU frame recording failed: {message}") + } + } + } +} + +impl std::error::Error for GraphicsRuntimeError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GraphicsPublishOutcome { + Published, + Replaced, +} + +pub struct GraphicsFrameToken { + epoch: u64, + frame: Arc, + owner: Arc, + recorded: AtomicBool, +} + +impl GraphicsFrameToken { + pub fn info(&self) -> GraphicsFrameInfo { + self.frame.info() + } +} + +#[derive(Clone)] +pub struct GraphicsFramePublisher { + inner: Arc, +} + +impl GraphicsFramePublisher { + pub fn clear(&self) { + lock_scene(&self.inner.scene).latest = None; + } + + pub fn context(&self) -> Option { + let scene = lock_scene(&self.inner.scene); + scene.context.map(|context| GraphicsContextLease { + context, + epoch: scene.epoch, + }) + } + + pub fn publish( + &self, + lease: GraphicsContextLease, + frame: Arc, + ) -> Result { + let outcome = { + let mut scene = lock_scene(&self.inner.scene); + if scene.context.is_none() { + return Err(GraphicsRuntimeError::SceneGraphInactive); + } + if scene.epoch != lease.epoch || scene.context != Some(lease.context) { + return Err(GraphicsRuntimeError::StaleFrame); + } + let replaced = scene.latest.replace(FrameSlot { + epoch: lease.epoch, + frame, + }); + if replaced.is_some() { + GraphicsPublishOutcome::Replaced + } else { + GraphicsPublishOutcome::Published + } + }; + (self.inner.frame_available)(); + Ok(outcome) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GraphicsContextLease { + context: GraphicsContext, + epoch: u64, +} + +impl GraphicsContextLease { + pub fn context(self) -> GraphicsContext { + self.context + } +} + +#[derive(Clone)] +pub struct RenderThreadGraphics { + inner: Arc, +} + +impl RenderThreadGraphics { + pub fn new( + frame_available: impl Fn() + Send + Sync + 'static, + ) -> (Self, GraphicsFramePublisher) { + let inner = Arc::new(GraphicsRuntimeInner { + scene: Mutex::new(SceneGraphState::default()), + frame_available: Box::new(frame_available), + }); + ( + Self { + inner: Arc::clone(&inner), + }, + GraphicsFramePublisher { inner }, + ) + } + + pub fn initialize(&self, context: GraphicsContext) -> Result<(), GraphicsRuntimeError> { + let context = context.validate()?; + let current_thread = thread::current().id(); + let mut scene = lock_scene(&self.inner.scene); + if let Some(render_thread) = scene.render_thread { + if render_thread != current_thread { + return Err(GraphicsRuntimeError::WrongThread); + } + if scene.context == Some(context) { + return Ok(()); + } + } + scene.latest = None; + scene.epoch = scene.epoch.wrapping_add(1); + scene.context = Some(context); + scene.render_thread = Some(current_thread); + Ok(()) + } + + pub fn acquire_latest(&self) -> Result { + let mut scene = lock_scene(&self.inner.scene); + require_render_thread(&scene)?; + let slot = scene.latest.take().ok_or(GraphicsRuntimeError::NoFrame)?; + if slot.epoch != scene.epoch { + return Err(GraphicsRuntimeError::StaleFrame); + } + Ok(GraphicsFrameToken { + epoch: slot.epoch, + frame: slot.frame, + owner: Arc::clone(&self.inner), + recorded: AtomicBool::new(false), + }) + } + + pub fn record( + &self, + frame: &GraphicsFrameToken, + command: GraphicsRecordCommand, + ) -> Result { + let command = command.validate()?; + if !Arc::ptr_eq(&self.inner, &frame.owner) { + return Err(GraphicsRuntimeError::StaleFrame); + } + let context = { + let scene = lock_scene(&self.inner.scene); + require_render_thread(&scene)?; + if frame.epoch != scene.epoch { + return Err(GraphicsRuntimeError::StaleFrame); + } + scene + .context + .ok_or(GraphicsRuntimeError::SceneGraphInactive)? + }; + if frame.recorded.swap(true, Ordering::AcqRel) { + return Err(GraphicsRuntimeError::FrameAlreadyRecorded); + } + frame + .frame + .record(context, command) + .map_err(GraphicsRuntimeError::RecordFailed) + } + + pub fn shutdown(&self) -> Result<(), GraphicsRuntimeError> { + let mut scene = lock_scene(&self.inner.scene); + if scene.context.is_none() { + return Ok(()); + } + require_render_thread(&scene)?; + scene.latest = None; + scene.context = None; + scene.render_thread = None; + scene.epoch = scene.epoch.wrapping_add(1); + Ok(()) + } + + pub fn is_active(&self) -> bool { + lock_scene(&self.inner.scene).context.is_some() + } +} + +struct GraphicsRuntimeInner { + scene: Mutex, + frame_available: Box, +} + +#[derive(Default)] +struct SceneGraphState { + context: Option, + render_thread: Option, + epoch: u64, + latest: Option, +} + +struct FrameSlot { + epoch: u64, + frame: Arc, +} + +fn require_render_thread(scene: &SceneGraphState) -> Result<(), GraphicsRuntimeError> { + let render_thread = scene + .render_thread + .ok_or(GraphicsRuntimeError::SceneGraphInactive)?; + if render_thread != thread::current().id() { + return Err(GraphicsRuntimeError::WrongThread); + } + Ok(()) +} + +fn lock_scene(scene: &Mutex) -> std::sync::MutexGuard<'_, SceneGraphState> { + scene + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[cfg(target_os = "macos")] + #[test] + fn macos_frame_implements_shared_graphics_frame_contract() { + fn assert_graphics_frame() {} + assert_graphics_frame::(); + } + + struct TestFrame { + sequence: u64, + drops: Arc, + records: Arc>>, + } + + impl GraphicsFrame for TestFrame { + fn info(&self) -> GraphicsFrameInfo { + GraphicsFrameInfo { + width: 1920, + height: 1080, + sequence: self.sequence, + presentation_time_ns: self.sequence * 1_000, + } + } + + fn record( + &self, + context: GraphicsContext, + command: GraphicsRecordCommand, + ) -> Result { + self.records + .lock() + .expect("record calls") + .push((context, command)); + Ok(GraphicsRecordedFrame { + resource: 10, + resource_view: 11, + width: 1920, + height: 1080, + frame_slot: command.frame_slot, + generation: self.sequence, + presentation_time_ns: self.sequence * 1_000, + }) + } + } + + impl Drop for TestFrame { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::Relaxed); + } + } + + fn context() -> GraphicsContext { + GraphicsContext { + api: GraphicsApi::Vulkan, + instance: 1, + physical_device: 2, + device: 3, + queue: 4, + queue_family_index: 5, + } + } + + fn command() -> GraphicsRecordCommand { + GraphicsRecordCommand { + command_buffer: 6, + frame_slot: 7, + } + } + + fn frame( + sequence: u64, + drops: &Arc, + records: &Arc>>, + ) -> Arc { + Arc::new(TestFrame { + sequence, + drops: Arc::clone(drops), + records: Arc::clone(records), + }) + } + + #[test] + fn mailbox_keeps_only_the_latest_frame_and_transfers_ownership_to_the_token() { + let notifications = Arc::new(AtomicUsize::new(0)); + let callback_notifications = Arc::clone(¬ifications); + let (graphics, publisher) = RenderThreadGraphics::new(move || { + callback_notifications.fetch_add(1, Ordering::Relaxed); + }); + graphics.initialize(context()).expect("graphics context"); + let lease = publisher.context().expect("context lease"); + let drops = Arc::new(AtomicUsize::new(0)); + let records = Arc::new(Mutex::new(Vec::new())); + + assert_eq!( + publisher + .publish(lease, frame(1, &drops, &records)) + .expect("first frame"), + GraphicsPublishOutcome::Published + ); + assert_eq!( + publisher + .publish(lease, frame(2, &drops, &records)) + .expect("replacement frame"), + GraphicsPublishOutcome::Replaced + ); + assert_eq!(drops.load(Ordering::Relaxed), 1); + assert_eq!(notifications.load(Ordering::Relaxed), 2); + + let token = graphics.acquire_latest().expect("latest frame"); + assert_eq!(token.info().sequence, 2); + assert_eq!( + graphics.acquire_latest().err(), + Some(GraphicsRuntimeError::NoFrame) + ); + assert_eq!(drops.load(Ordering::Relaxed), 1); + drop(token); + assert_eq!(drops.load(Ordering::Relaxed), 2); + } + + #[test] + fn record_uses_the_active_context_and_callers_command_stream() { + let (graphics, publisher) = RenderThreadGraphics::new(|| {}); + graphics.initialize(context()).expect("graphics context"); + let lease = publisher.context().expect("context lease"); + let drops = Arc::new(AtomicUsize::new(0)); + let records = Arc::new(Mutex::new(Vec::new())); + publisher + .publish(lease, frame(1, &drops, &records)) + .expect("frame"); + let token = graphics.acquire_latest().expect("latest frame"); + + let output = graphics.record(&token, command()).expect("record frame"); + + assert_eq!( + &*records.lock().expect("record calls"), + &[(context(), command())] + ); + assert_eq!(output.resource, 10); + assert_eq!(output.resource_view, 11); + } + + #[test] + fn shutdown_drops_pending_frames_and_invalidates_tokens_and_leases() { + let (graphics, publisher) = RenderThreadGraphics::new(|| {}); + graphics.initialize(context()).expect("graphics context"); + let old_lease = publisher.context().expect("context lease"); + let drops = Arc::new(AtomicUsize::new(0)); + let records = Arc::new(Mutex::new(Vec::new())); + publisher + .publish(old_lease, frame(1, &drops, &records)) + .expect("first frame"); + let token = graphics.acquire_latest().expect("latest frame"); + publisher + .publish(old_lease, frame(2, &drops, &records)) + .expect("pending frame"); + + graphics.shutdown().expect("scene graph shutdown"); + assert_eq!(drops.load(Ordering::Relaxed), 1); + assert_eq!( + graphics.record(&token, command()).err(), + Some(GraphicsRuntimeError::SceneGraphInactive) + ); + assert_eq!( + publisher + .publish(old_lease, frame(3, &drops, &records)) + .err(), + Some(GraphicsRuntimeError::SceneGraphInactive) + ); + + graphics.initialize(context()).expect("new scene graph"); + assert_eq!( + graphics.record(&token, command()).err(), + Some(GraphicsRuntimeError::StaleFrame) + ); + assert_eq!( + publisher + .publish(old_lease, frame(4, &drops, &records)) + .err(), + Some(GraphicsRuntimeError::StaleFrame) + ); + } + + #[test] + fn graphics_calls_are_bound_to_the_initializing_render_thread() { + let (graphics, _publisher) = RenderThreadGraphics::new(|| {}); + graphics.initialize(context()).expect("graphics context"); + let other_thread = std::thread::spawn(move || graphics.acquire_latest().err()) + .join() + .expect("render-thread check"); + assert_eq!(other_thread, Some(GraphicsRuntimeError::WrongThread)); + } +} diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/lib.rs index 6bb15d7c6..b63be502f 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform/src/lib.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform/src/lib.rs @@ -1,3 +1,5 @@ +mod embedded_input; +mod graphics; #[cfg(target_os = "linux")] mod linux_backend; #[cfg(target_os = "linux")] @@ -7,24 +9,45 @@ mod linux_xinput; #[cfg(target_os = "macos")] mod macos_backend; mod media; -mod microphone; mod native_surface; mod output; mod queue; mod recording; mod runtime; #[cfg(target_os = "windows")] +mod windows_graphics; +#[cfg(target_os = "windows")] mod windows_raw_input; +pub use embedded_input::{EmbeddedInputCapture, EmbeddedLocalAction}; +pub use graphics::{ + GraphicsApi, GraphicsContext, GraphicsContextLease, GraphicsFrame, GraphicsFrameInfo, + GraphicsFramePublisher, GraphicsFrameToken, GraphicsPublishOutcome, GraphicsRecordCommand, + GraphicsRecordedFrame, GraphicsRuntimeError, RenderThreadGraphics, +}; pub use media::{ CapturedInput, CapturedInputQueue, CapturedInputSample, EncodedFrame, EncodedRecordingReceiver, MediaCodec, MediaColorQuality, MediaControl, MediaFeedback, MediaSession, MediaSink, MediaStreamConfig, MediaVideoCodec, PushOutcome, ShortcutChord, StreamShortcutAction, StreamShortcutBindings, }; -pub use microphone::{EncodedMicrophonePacket, EncodedMicrophoneQueue, MicrophoneCapture}; +#[cfg(target_os = "linux")] +pub use opennow_streamer_platform_linux::{LinuxGpuFrame, LinuxGpuFrameProducer}; +#[cfg(target_os = "macos")] +pub use opennow_streamer_platform_macos::{ + AdoptedMetalContext, EmbeddedFrameProducer, MetalFrame, MetalRecordedFrame, +}; +#[cfg(target_os = "windows")] +pub use opennow_streamer_platform_windows::{ + AdoptedD3d11Context, D3d11Frame, D3d11FrameProducer, D3d11FrameSubmitter, D3d11RecordedFrame, +}; pub use recording::{RecordingSummary, record_matroska}; -pub use runtime::{MainThreadHost, MediaRuntime, MediaRuntimeControl, create_runtime}; +pub use runtime::{ + MainThreadHost, MediaRuntime, MediaRuntimeControl, create_embedded_runtime, + create_embedded_runtime_with_input, create_runtime, +}; +#[cfg(feature = "test-runtime")] +pub use runtime::{TestMediaRuntimeHost, create_test_runtime}; use opennow_streamer_protocol::{CodecCapability, VideoBackendCapability}; @@ -55,6 +78,31 @@ pub fn video_backends() -> Vec { backends } +pub fn embedded_video_backends() -> Vec { + #[cfg(target_os = "linux")] + let mut backends = linux_backend::video_backends(); + #[cfg(target_os = "windows")] + let mut backends = { + use opennow_streamer_platform_windows::WindowsGraphicsApi; + vec![windows_hardware_backend( + WindowsGraphicsApi::D3d11, + "d3d11", + "d3d11-nv12", + )] + }; + #[cfg(target_os = "macos")] + let mut backends = vec![hardware_backend()]; + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + let mut backends = Vec::new(); + apply_backend_policy( + &mut backends, + std::env::var("OPENNOW_NATIVE_VIDEO_BACKEND") + .ok() + .as_deref(), + ); + backends +} + fn apply_backend_policy(backends: &mut [VideoBackendCapability], requested: Option<&str>) { let requested = requested .map(str::trim) @@ -421,4 +469,26 @@ mod tests { ); } } + + #[test] + fn embedded_capabilities_exclude_standalone_only_backends() { + let backends = embedded_video_backends(); + assert!(backends.iter().all(|backend| backend.backend != "software")); + #[cfg(target_os = "windows")] + assert_eq!( + backends + .iter() + .map(|backend| backend.backend) + .collect::>(), + vec!["d3d11"] + ); + #[cfg(target_os = "macos")] + assert_eq!( + backends + .iter() + .map(|backend| backend.backend) + .collect::>(), + vec!["videotoolbox"] + ); + } } diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/media.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/media.rs index f1a00686f..66a60cbc9 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform/src/media.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform/src/media.rs @@ -10,9 +10,9 @@ use openh264::decoder::{Decoder as OpenH264Decoder, DecoderConfig}; use openh264::formats::YUVSource; use opus::{Channels, Decoder as OpusNativeDecoder}; -#[cfg(target_os = "windows")] -use crate::output::WindowsBridge; use crate::output::{DecodedVideoFrame, OutputBuffers}; +#[cfg(target_os = "windows")] +use crate::output::{HeadlessAudioOutput, WindowsBridge}; use crate::queue::{BoundedQueue, PushResult}; use crate::runtime::HostCommand; @@ -217,7 +217,6 @@ pub enum StreamShortcutAction { ToggleFullscreen, StopStream, ToggleAntiAfk, - ToggleMicrophone, Screenshot, ToggleRecording, } @@ -230,7 +229,6 @@ impl StreamShortcutAction { Self::ToggleFullscreen => "toggle-fullscreen", Self::StopStream => "stop-stream", Self::ToggleAntiAfk => "toggle-anti-afk", - Self::ToggleMicrophone => "toggle-microphone", Self::Screenshot => "screenshot", Self::ToggleRecording => "toggle-recording", } @@ -239,7 +237,7 @@ impl StreamShortcutAction { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct StreamShortcutBindings { - bindings: [(StreamShortcutAction, Option); 8], + bindings: [(StreamShortcutAction, Option); 7], } impl StreamShortcutBindings { @@ -273,10 +271,6 @@ impl StreamShortcutBindings { StreamShortcutAction::ToggleAntiAfk, read("toggleAntiAfk", "Ctrl+Shift+K"), ), - ( - StreamShortcutAction::ToggleMicrophone, - read("toggleMicrophone", "Ctrl+Shift+M"), - ), ( StreamShortcutAction::Screenshot, read("screenshot", "Ctrl+F11"), @@ -653,6 +647,12 @@ pub struct MediaSession { audio_worker: Option>, #[cfg(target_os = "linux")] linux_monitor: Option>, + embedded_host_worker: Option>, + #[cfg(target_os = "windows")] + headless_audio: Option, + #[cfg(target_os = "windows")] + embedded_d3d11: Option>>, + embedded_frames: Option, host_commands: Sender, } @@ -785,6 +785,12 @@ impl MediaSession { audio_worker: Some(audio_worker), #[cfg(target_os = "linux")] linux_monitor: None, + embedded_host_worker: None, + #[cfg(target_os = "windows")] + headless_audio: None, + #[cfg(target_os = "windows")] + embedded_d3d11: None, + embedded_frames: None, host_commands, }) } @@ -849,6 +855,10 @@ impl MediaSession { audio_worker: Some(audio_worker), #[cfg(target_os = "linux")] linux_monitor: None, + embedded_host_worker: None, + #[cfg(target_os = "windows")] + headless_audio: None, + embedded_frames: None, host_commands, }) } @@ -934,202 +944,879 @@ impl MediaSession { video_worker: Some(video_worker), audio_worker: Some(audio_worker), linux_monitor: Some(linux_monitor), + embedded_host_worker: None, + #[cfg(target_os = "windows")] + headless_audio: None, + embedded_frames: None, host_commands, }) } - pub fn sink(&self) -> MediaSink { - self.sink.clone() - } - - pub fn control(&self) -> MediaControl { - MediaControl { - shared: Arc::clone(&self.sink.shared), - host_commands: self.host_commands.clone(), - } - } - - pub fn set_paused(&self, paused: bool) { - self.sink.shared.paused.store(paused, Ordering::Release); - self.sink.shared.video.clear(); - self.sink.shared.audio.clear(); - self.sink.shared.output.clear(); + pub(crate) fn spawn_embedded( + output: Arc, + feedback: Sender, + host_commands: Sender, + stream: MediaStreamConfig, + frames: crate::GraphicsFramePublisher, + #[cfg(target_os = "linux")] linux_selection: LinuxVideoSelection, + ) -> Result { #[cfg(target_os = "linux")] - if let Some(session) = self - .sink - .shared - .linux_session - .lock() - .unwrap_or_else(|error| error.into_inner()) - .as_ref() { - let _ = session.set_paused(paused); - } - if !paused { - self.sink - .shared - .video_desynced - .store(true, Ordering::Release); - self.sink - .shared - .keyframe_requested - .store(false, Ordering::Release); - } - let _ = self.host_commands.send(HostCommand::Pause { - paused, - reply: None, - }); - } - - pub fn stop(mut self) { - self.stop_inner(); - } - - fn stop_inner(&mut self) { - self.control().stop(); - if let Some(worker) = self.video_worker.take() { - let _ = worker.join(); - } - if let Some(worker) = self.audio_worker.take() { - let _ = worker.join(); - } - #[cfg(target_os = "linux")] - if let Some(worker) = self.linux_monitor.take() { - let _ = worker.join(); + return Self::spawn_embedded_linux( + output, + feedback, + host_commands, + stream, + frames, + linux_selection, + ); } #[cfg(target_os = "macos")] { - self.sink - .shared - .mac_sink - .lock() - .unwrap_or_else(|error| error.into_inner()) - .take(); + return Self::spawn_embedded_macos(output, feedback, host_commands, stream, frames); } - } -} - -impl MediaControl { - pub fn subscribe_recording( - &self, - ) -> Result<(MediaStreamConfig, EncodedRecordingReceiver), String> { - self.shared - .recording_tap - .subscribe() - .map(|receiver| (self.shared.stream, receiver)) - } - - pub fn unsubscribe_recording(&self) { - self.shared.recording_tap.unsubscribe(); - } - - pub fn update_cursor(&self, bytes: Vec) { - let _ = self.host_commands.send(HostCommand::Cursor(bytes)); - } - - pub fn stop(&self) { - if self.shared.stopped.swap(true, Ordering::AcqRel) { - return; + #[cfg(target_os = "windows")] + { + return Self::spawn_embedded_windows(output, feedback, host_commands, stream, frames); } - self.shared.video.close(); - self.shared.audio.close(); - self.shared.recording_tap.unsubscribe(); - self.shared.output.clear(); - let _ = self.host_commands.send(HostCommand::Stop); + #[allow(unreachable_code)] + Err("embedded media is unsupported on this platform".to_owned()) } -} -#[cfg(target_os = "windows")] -fn run_windows_video(shared: Arc, maximum_fps: u32) { - use opennow_streamer_platform_windows::{ - EncodedVideoFrame, PushOutcome as WindowsPushOutcome, VideoCodec, - }; - - let mut sample_clock = AdaptiveSampleClock::new(maximum_fps); - while let Some(frame) = shared.video.pop() { - if shared.paused.load(Ordering::Acquire) { - continue; - } - if shared.windows_bridge.use_software() { - shared.video_desynced.store(true, Ordering::Release); - match H264Decoder::new() { - Ok(decoder) => run_video_decoder_from(shared, decoder, Some(frame)), - Err(message) => { - let _ = shared.feedback.send(MediaFeedback::DecoderError { - codec: "h264", - message, - }); - } - } - return; - } - if (shared.video_desynced.load(Ordering::Acquire) - || shared.windows_bridge.keyframe_required()) - && !frame.keyframe - { - continue; - } - let Some(backend) = shared.windows_bridge.backend() else { - shared.video_desynced.store(true, Ordering::Release); - request_keyframe(&shared, &frame.mid, "D3D11 backend is unavailable"); - continue; + #[cfg(target_os = "linux")] + fn spawn_embedded_linux( + output: Arc, + feedback: Sender, + host_commands: Sender, + stream: MediaStreamConfig, + frames: crate::GraphicsFramePublisher, + linux_selection: LinuxVideoSelection, + ) -> Result { + let LinuxVideoPath::Hardware(decoder_preference) = linux_selection.path else { + return Err(linux_selection.fallback_reason.unwrap_or_else(|| { + "embedded Linux output requires a native NV12 decoder".to_owned() + })); }; - shared.windows_bridge.set_last_video_mid(&frame.mid); - let timestamp_100ns = media_timestamp_100ns(frame.timestamp, frame.clock_rate_hz); - let duration_100ns = sample_clock.observe(timestamp_100ns); - let reset_decoder = frame.keyframe - && (shared.video_desynced.load(Ordering::Acquire) - || shared.windows_bridge.keyframe_required()); - match backend.submit_video(EncodedVideoFrame { - codec: match frame.codec { - MediaCodec::H264 => VideoCodec::H264, - MediaCodec::H265 => VideoCodec::H265, - MediaCodec::Av1 => VideoCodec::Av1, - _ => continue, - }, - data: frame.data.to_vec(), - timestamp_100ns, - duration_100ns, - key_frame: frame.keyframe, - reset_decoder, - }) { - Ok(WindowsPushOutcome::Queued) => { - report_video_frame_accepted(&shared, &frame); - if frame.keyframe { - shared.video_desynced.store(false, Ordering::Release); - shared.keyframe_requested.store(false, Ordering::Release); - shared.windows_bridge.accept_keyframe(); - } - } - Ok(WindowsPushOutcome::Paused) => {} - Ok(WindowsPushOutcome::DroppedOldest) => { - shared.video_desynced.store(true, Ordering::Release); - shared.windows_bridge.require_keyframe(); - let _ = shared.feedback.send(MediaFeedback::QueueDropped { - media: "d3d11-video", - count: 1, - }); - request_keyframe(&shared, &frame.mid, "D3D11 input queue overflow"); + let format = opennow_streamer_platform_linux::StreamFormat::video_default( + stream.width, + stream.height, + ) + .map_err(|error| error.to_string())?; + let mut config = opennow_streamer_platform_linux::SessionConfig::new(format); + config.codec = match stream.codec { + MediaVideoCodec::H264 => opennow_streamer_platform_linux::VideoCodec::H264, + MediaVideoCodec::H265 => opennow_streamer_platform_linux::VideoCodec::H265, + MediaVideoCodec::Av1 => opennow_streamer_platform_linux::VideoCodec::Av1, + }; + config.decoder_preference = decoder_preference; + let session = opennow_streamer_platform_linux::LinuxSession::start(config) + .map_err(|error| error.to_string())?; + let shared = Arc::new(SharedPipeline { + video: Arc::new(BoundedQueue::new(VIDEO_QUEUE_CAPACITY)), + audio: Arc::new(BoundedQueue::new(AUDIO_QUEUE_CAPACITY)), + output, + feedback, + paused: AtomicBool::new(false), + video_desynced: AtomicBool::new(true), + keyframe_requested: AtomicBool::new(false), + stopped: AtomicBool::new(false), + recording_tap: RecordingTap::default(), + stream, + linux_session: Mutex::new(Some(session)), + linux_software_fallback: Arc::new(AtomicBool::new(false)), + linux_video_mid: Mutex::new(String::new()), + linux_codec: stream.codec, + }); + let video_shared = Arc::clone(&shared); + let video_worker = thread::Builder::new() + .name("opennow-embedded-linux-video-submit".to_owned()) + .spawn(move || run_embedded_linux_video(video_shared)) + .map_err(|error| format!("failed to start embedded Linux video submitter: {error}"))?; + let audio_shared = Arc::clone(&shared); + let audio_worker = match thread::Builder::new() + .name("opennow-embedded-linux-audio-submit".to_owned()) + .spawn(move || run_embedded_linux_audio(audio_shared)) + { + Ok(worker) => worker, + Err(error) => { + shared.video.close(); + let _ = video_worker.join(); + stop_linux_session(&shared); + return Err(format!( + "failed to start embedded Linux audio submitter: {error}" + )); } + }; + let producer = crate::LinuxGpuFrameProducer::new(8).map_err(|error| error.to_string())?; + let embedded_frames = frames.clone(); + let monitor_shared = Arc::clone(&shared); + let linux_monitor = match thread::Builder::new() + .name("opennow-embedded-linux-frame-publisher".to_owned()) + .spawn(move || run_embedded_linux_monitor(monitor_shared, frames, producer)) + { + Ok(worker) => worker, Err(error) => { - shared.video_desynced.store(true, Ordering::Release); - shared.windows_bridge.require_keyframe(); - let _ = shared.feedback.send(MediaFeedback::DecoderError { - codec: match frame.codec { - MediaCodec::H264 => "h264", - MediaCodec::H265 => "h265", - MediaCodec::Av1 => "av1", - _ => "video", - }, - message: error.to_string(), - }); - request_keyframe(&shared, &frame.mid, "D3D11 decoder rejected an access unit"); + shared.video.close(); + shared.audio.close(); + let _ = video_worker.join(); + let _ = audio_worker.join(); + stop_linux_session(&shared); + return Err(format!( + "failed to start embedded Linux frame publisher: {error}" + )); } - } - } -} - -#[cfg(any(target_os = "windows", test))] + }; + Ok(Self { + sink: MediaSink { shared }, + video_worker: Some(video_worker), + audio_worker: Some(audio_worker), + linux_monitor: Some(linux_monitor), + embedded_host_worker: None, + #[cfg(target_os = "windows")] + headless_audio: None, + embedded_frames: Some(embedded_frames), + host_commands, + }) + } + + #[cfg(target_os = "macos")] + fn spawn_embedded_macos( + output: Arc, + feedback: Sender, + _host_commands: Sender, + stream: MediaStreamConfig, + frames: crate::GraphicsFramePublisher, + ) -> Result { + let shared = Arc::new(SharedPipeline { + video: Arc::new(BoundedQueue::new(macos_video_queue_capacity(stream.fps))), + audio: Arc::new(BoundedQueue::new(AUDIO_QUEUE_CAPACITY)), + output, + feedback, + paused: AtomicBool::new(false), + video_desynced: AtomicBool::new(true), + keyframe_requested: AtomicBool::new(false), + stopped: AtomicBool::new(false), + recording_tap: RecordingTap::default(), + stream, + mac_sink: Mutex::new(None), + mac_software_fallback: AtomicBool::new(false), + }); + let (host_commands, host_receiver) = std::sync::mpsc::channel(); + let embedded_frames = frames.clone(); + let embedded_host_worker = thread::Builder::new() + .name("opennow-embedded-videotoolbox-host".to_owned()) + .spawn(move || { + use opennow_streamer_platform_macos::{ + AudioFormat, EmbeddedBackendConfig, H264Format, H265Format, MacOsBackend, + QueueLimits, VideoColorSpace, + }; + + let mut backend: Option = None; + let mut paused = false; + while let Ok(command) = host_receiver.recv() { + let start = |video| { + let publisher = frames.clone(); + MacOsBackend::start_embedded_with_publisher( + EmbeddedBackendConfig { + video, + audio: AudioFormat::OPUS_STEREO_48KHZ, + queues: QueueLimits::default(), + }, + move |frame| { + let Some(lease) = publisher.context() else { + return false; + }; + matches!( + publisher.publish(lease, Arc::new(frame)), + Ok(crate::GraphicsPublishOutcome::Replaced) + ) + }, + ) + }; + match command { + HostCommand::Start { reply, .. } => { + let _ = reply + .send(Err("embedded VideoToolbox host is already session-scoped" + .to_owned())); + } + HostCommand::ConfigureMacH264 { + parameter_sets, + reply, + } => { + let result = start( + H264Format::new(parameter_sets, VideoColorSpace::Bt709).into(), + ) + .and_then(|mut started| { + started.set_paused(paused)?; + let sink = started.sink(); + backend = Some(started); + Ok(crate::runtime::MacH264Configuration::Hardware(sink)) + }) + .map_err(|error| error.to_string()); + let _ = reply.send(result); + } + HostCommand::ConfigureMacH265 { + parameter_sets, + reply, + } => { + let result = start( + H265Format::new(parameter_sets, VideoColorSpace::Bt709).into(), + ) + .and_then(|mut started| { + started.set_paused(paused)?; + let sink = started.sink(); + backend = Some(started); + Ok(sink) + }) + .map_err(|error| error.to_string()); + let _ = reply.send(result); + } + HostCommand::ConfigureMacAv1 { format, reply } => { + let result = start(format.into()) + .and_then(|mut started| { + started.set_paused(paused)?; + let sink = started.sink(); + backend = Some(started); + Ok(sink) + }) + .map_err(|error| error.to_string()); + let _ = reply.send(result); + } + HostCommand::Pause { + paused: new_paused, + reply, + } => { + let result = backend.as_mut().map_or(Ok(()), |backend| { + backend + .set_paused(new_paused) + .map_err(|error| error.to_string()) + }); + if result.is_ok() { + paused = new_paused; + } + if let Some(reply) = reply { + let _ = reply.send(result); + } + } + HostCommand::Stop | HostCommand::Shutdown => break, + HostCommand::Surface { reply, .. } | HostCommand::Control { reply, .. } => { + let _ = reply.send(Ok(())); + } + HostCommand::Cursor(_) => {} + } + } + }) + .map_err(|error| format!("failed to start embedded VideoToolbox host: {error}"))?; + let video_shared = Arc::clone(&shared); + let video_commands = host_commands.clone(); + let video_worker = match thread::Builder::new() + .name(format!( + "opennow-embedded-videotoolbox-{}-submit", + stream.codec.label() + )) + .spawn(move || match stream.codec { + MediaVideoCodec::H264 => { + run_macos_h264_video(video_shared, video_commands, stream.fps); + } + MediaVideoCodec::H265 => { + run_macos_h265_video(video_shared, video_commands, stream.fps); + } + MediaVideoCodec::Av1 => run_macos_av1_video(video_shared, video_commands, stream), + }) { + Ok(worker) => worker, + Err(error) => { + let _ = host_commands.send(HostCommand::Shutdown); + let _ = embedded_host_worker.join(); + return Err(format!( + "failed to start embedded VideoToolbox submitter: {error}" + )); + } + }; + let audio_shared = Arc::clone(&shared); + let audio_worker = match thread::Builder::new() + .name("opennow-embedded-coreaudio-submit".to_owned()) + .spawn(move || run_macos_audio(audio_shared)) + { + Ok(worker) => worker, + Err(error) => { + shared.video.close(); + let _ = video_worker.join(); + let _ = host_commands.send(HostCommand::Shutdown); + let _ = embedded_host_worker.join(); + return Err(format!( + "failed to start embedded CoreAudio submitter: {error}" + )); + } + }; + Ok(Self { + sink: MediaSink { shared }, + video_worker: Some(video_worker), + audio_worker: Some(audio_worker), + embedded_host_worker: Some(embedded_host_worker), + embedded_frames: Some(embedded_frames), + host_commands, + }) + } + + #[cfg(target_os = "windows")] + fn spawn_embedded_windows( + output: Arc, + feedback: Sender, + host_commands: Sender, + stream: MediaStreamConfig, + frames: crate::GraphicsFramePublisher, + ) -> Result { + let audio = HeadlessAudioOutput::start(Arc::clone(&output))?; + let bridge = Arc::new(WindowsBridge::new()); + let shared = Arc::new(SharedPipeline { + video: Arc::new(BoundedQueue::new(VIDEO_QUEUE_CAPACITY)), + audio: Arc::new(BoundedQueue::new(AUDIO_QUEUE_CAPACITY)), + output, + feedback, + paused: AtomicBool::new(false), + video_desynced: AtomicBool::new(true), + keyframe_requested: AtomicBool::new(false), + stopped: AtomicBool::new(false), + recording_tap: RecordingTap::default(), + stream, + windows_bridge: bridge, + }); + let producer = Arc::new(Mutex::new(EmbeddedD3d11State::new(stream))); + let embedded_frames = frames.clone(); + let video_shared = Arc::clone(&shared); + let video_producer = Arc::clone(&producer); + let video_worker = thread::Builder::new() + .name("opennow-embedded-d3d11-submit".to_owned()) + .spawn(move || { + let mut clock = AdaptiveSampleClock::new(stream.fps); + let mut sequence = 0_u64; + while let Some(frame) = video_shared.video.pop() { + if video_shared.paused.load(Ordering::Acquire) { + continue; + } + if video_shared.video_desynced.load(Ordering::Acquire) && !frame.keyframe { + continue; + } + let timestamp_100ns = + media_timestamp_100ns(frame.timestamp, frame.clock_rate_hz); + let duration_100ns = clock.observe(timestamp_100ns); + let encoded = opennow_streamer_platform_windows::EncodedVideoFrame { + codec: match frame.codec { + MediaCodec::H264 => opennow_streamer_platform_windows::VideoCodec::H264, + MediaCodec::H265 => opennow_streamer_platform_windows::VideoCodec::H265, + MediaCodec::Av1 => opennow_streamer_platform_windows::VideoCodec::Av1, + _ => continue, + }, + data: frame.data.to_vec(), + timestamp_100ns, + duration_100ns, + key_frame: frame.keyframe, + reset_decoder: frame.keyframe + && video_shared.video_desynced.load(Ordering::Acquire), + }; + let dropped = video_producer + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(encoded); + if dropped { + video_shared.video_desynced.store(true, Ordering::Release); + let _ = video_shared.feedback.send(MediaFeedback::QueueDropped { + media: "d3d11-video", + count: 1, + }); + } else { + report_video_frame_accepted(&video_shared, &frame); + if frame.keyframe { + video_shared.video_desynced.store(false, Ordering::Release); + video_shared + .keyframe_requested + .store(false, Ordering::Release); + } + } + let Some(lease) = frames.context() else { + continue; + }; + sequence = sequence.wrapping_add(1).max(1); + let pending = PendingD3d11Frame { + state: Arc::clone(&video_producer), + info: crate::GraphicsFrameInfo { + width: stream.width, + height: stream.height, + sequence, + presentation_time_ns: u64::try_from(timestamp_100ns.max(0)) + .unwrap_or(0) + .saturating_mul(100), + }, + }; + let _ = frames.publish(lease, Arc::new(pending)); + } + }) + .map_err(|error| format!("failed to start embedded D3D11 submitter: {error}"))?; + let audio_shared = Arc::clone(&shared); + let audio_decoder = OpusDecoder::new(2)?; + let audio_worker = match thread::Builder::new() + .name("opennow-embedded-opus-decode".to_owned()) + .spawn(move || run_audio_decoder(audio_shared, audio_decoder)) + { + Ok(worker) => worker, + Err(error) => { + shared.video.close(); + let _ = video_worker.join(); + return Err(format!("failed to start embedded Opus decoder: {error}")); + } + }; + Ok(Self { + sink: MediaSink { shared }, + video_worker: Some(video_worker), + audio_worker: Some(audio_worker), + embedded_host_worker: None, + headless_audio: Some(audio), + embedded_d3d11: Some(producer), + embedded_frames: Some(embedded_frames), + host_commands, + }) + } + + #[cfg(feature = "test-runtime")] + pub(crate) fn spawn_test( + output: Arc, + feedback: Sender, + host_commands: Sender, + stream: MediaStreamConfig, + #[cfg(target_os = "windows")] windows_bridge: Arc, + #[cfg(target_os = "linux")] linux_software_fallback: Arc, + ) -> Result { + let shared = Arc::new(SharedPipeline { + video: Arc::new(BoundedQueue::new(VIDEO_QUEUE_CAPACITY)), + audio: Arc::new(BoundedQueue::new(AUDIO_QUEUE_CAPACITY)), + output, + feedback, + paused: AtomicBool::new(false), + video_desynced: AtomicBool::new(false), + keyframe_requested: AtomicBool::new(false), + stopped: AtomicBool::new(false), + recording_tap: RecordingTap::default(), + stream, + #[cfg(target_os = "macos")] + mac_sink: Mutex::new(None), + #[cfg(target_os = "macos")] + mac_software_fallback: AtomicBool::new(false), + #[cfg(target_os = "windows")] + windows_bridge, + #[cfg(target_os = "linux")] + linux_session: Mutex::new(None), + #[cfg(target_os = "linux")] + linux_software_fallback, + #[cfg(target_os = "linux")] + linux_video_mid: Mutex::new(String::new()), + #[cfg(target_os = "linux")] + linux_codec: stream.codec, + }); + let video_shared = Arc::clone(&shared); + let video_worker = thread::Builder::new() + .name("opennow-test-video-consumer".to_owned()) + .spawn(move || { + while let Some(frame) = video_shared.video.pop() { + report_video_frame_accepted(&video_shared, &frame); + } + }) + .map_err(|error| format!("failed to start test video consumer: {error}"))?; + let audio_shared = Arc::clone(&shared); + let audio_worker = match thread::Builder::new() + .name("opennow-test-audio-consumer".to_owned()) + .spawn(move || while audio_shared.audio.pop().is_some() {}) + { + Ok(worker) => worker, + Err(error) => { + shared.video.close(); + let _ = video_worker.join(); + return Err(format!("failed to start test audio consumer: {error}")); + } + }; + Ok(Self { + sink: MediaSink { shared }, + video_worker: Some(video_worker), + audio_worker: Some(audio_worker), + #[cfg(target_os = "linux")] + linux_monitor: None, + embedded_host_worker: None, + #[cfg(target_os = "windows")] + headless_audio: None, + #[cfg(target_os = "windows")] + embedded_d3d11: None, + embedded_frames: None, + host_commands, + }) + } + + pub fn sink(&self) -> MediaSink { + self.sink.clone() + } + + pub fn control(&self) -> MediaControl { + MediaControl { + shared: Arc::clone(&self.sink.shared), + host_commands: self.host_commands.clone(), + } + } + + pub fn set_paused(&self, paused: bool) { + self.sink.shared.paused.store(paused, Ordering::Release); + self.sink.shared.video.clear(); + self.sink.shared.audio.clear(); + self.sink.shared.output.clear(); + if let Some(frames) = &self.embedded_frames { + frames.clear(); + } + #[cfg(target_os = "windows")] + if let Some(state) = &self.embedded_d3d11 { + state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .reset(); + } + #[cfg(target_os = "linux")] + if let Some(session) = self + .sink + .shared + .linux_session + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + { + let _ = session.set_paused(paused); + } + if !paused { + self.sink + .shared + .video_desynced + .store(true, Ordering::Release); + self.sink + .shared + .keyframe_requested + .store(false, Ordering::Release); + } + #[cfg(target_os = "windows")] + if let Some(audio) = self.headless_audio.as_ref() { + audio.set_paused(paused); + } + let _ = self.host_commands.send(HostCommand::Pause { + paused, + reply: None, + }); + } + + pub fn stop(mut self) { + self.stop_inner(); + } + + fn stop_inner(&mut self) { + self.control().stop(); + if let Some(worker) = self.video_worker.take() { + let _ = worker.join(); + } + if let Some(worker) = self.audio_worker.take() { + let _ = worker.join(); + } + #[cfg(target_os = "linux")] + if let Some(worker) = self.linux_monitor.take() { + let _ = worker.join(); + } + #[cfg(target_os = "macos")] + { + self.sink + .shared + .mac_sink + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + } + if let Some(worker) = self.embedded_host_worker.take() { + let _ = worker.join(); + } + } +} + +#[cfg(target_os = "windows")] +struct EmbeddedD3d11State { + format: opennow_streamer_platform_windows::VideoFormat, + pending: VecDeque, + producer: Option<( + crate::GraphicsContext, + crate::D3d11FrameProducer, + crate::D3d11FrameSubmitter, + )>, +} + +#[cfg(target_os = "windows")] +impl EmbeddedD3d11State { + fn new(stream: MediaStreamConfig) -> Self { + use opennow_streamer_platform_windows::{ + VideoChromaFormat, VideoCodec, VideoFormat, VideoPixelFormat, + }; + use std::num::NonZeroU32; + + Self { + format: VideoFormat { + codec: match stream.codec { + MediaVideoCodec::H264 => VideoCodec::H264, + MediaVideoCodec::H265 => VideoCodec::H265, + MediaVideoCodec::Av1 => VideoCodec::Av1, + }, + width: stream.width, + height: stream.height, + frame_rate_numerator: NonZeroU32::new(stream.fps.max(1)).expect("non-zero fps"), + frame_rate_denominator: NonZeroU32::new(1).expect("one is non-zero"), + average_bitrate: stream.bitrate_bps.max(1), + pixel_format: match ( + stream.color_quality.bit_depth(), + stream.color_quality.is_444(), + ) { + (10, true) => VideoPixelFormat::Y410, + (10, false) => VideoPixelFormat::P010, + (_, true) => VideoPixelFormat::Ayuv, + _ => VideoPixelFormat::Nv12, + }, + chroma_format: if stream.color_quality.is_444() { + VideoChromaFormat::Cs444 + } else { + VideoChromaFormat::Cs420 + }, + full_range: false, + }, + pending: VecDeque::with_capacity( + opennow_streamer_platform_windows::ADAPTIVE_VIDEO_QUEUE_CAPACITY, + ), + producer: None, + } + } + + fn push(&mut self, frame: opennow_streamer_platform_windows::EncodedVideoFrame) -> bool { + let dropped = if self.pending.len() + == opennow_streamer_platform_windows::ADAPTIVE_VIDEO_QUEUE_CAPACITY + { + self.pending.pop_front(); + true + } else { + false + }; + self.pending.push_back(frame); + dropped + } + + fn reset(&mut self) { + self.pending.clear(); + self.producer = None; + } + + fn record( + &mut self, + context: crate::GraphicsContext, + command: crate::GraphicsRecordCommand, + ) -> Result { + use opennow_streamer_platform_windows::{ + AdoptedD3d11Context, D3d11FrameProducer, WindowsDecoderMode, + }; + + if context.api != crate::GraphicsApi::D3d11 { + return Err("an embedded Windows frame requires a D3D11 graphics context".to_owned()); + } + if self + .producer + .as_ref() + .is_none_or(|(adopted, _, _)| *adopted != context) + { + self.producer = None; + let (producer, submitter) = unsafe { + D3d11FrameProducer::new( + AdoptedD3d11Context { + device: context.device as *mut std::ffi::c_void, + immediate_context: context.queue as *mut std::ffi::c_void, + }, + self.format, + WindowsDecoderMode::Hardware, + ) + } + .map_err(|error| error.to_string())?; + self.producer = Some((context, producer, submitter)); + } + let (_, producer, submitter) = self.producer.as_ref().expect("producer initialized"); + while let Some(frame) = self.pending.pop_front() { + submitter + .submit_video(frame) + .map_err(|error| error.to_string())?; + } + let frame = producer + .acquire_latest() + .map_err(|error| error.to_string())? + .ok_or_else(|| "the D3D11 decoder has not produced a frame yet".to_owned())?; + let recorded = unsafe { + frame.record( + AdoptedD3d11Context { + device: context.device as *mut std::ffi::c_void, + immediate_context: context.queue as *mut std::ffi::c_void, + }, + command.frame_slot, + ) + } + .map_err(|error| error.to_string())?; + Ok(crate::GraphicsRecordedFrame { + resource: recorded.texture as usize as u64, + resource_view: 0, + width: recorded.width, + height: recorded.height, + frame_slot: recorded.frame_slot, + generation: recorded.generation, + presentation_time_ns: recorded.presentation_time_ns, + }) + } +} + +#[cfg(target_os = "windows")] +struct PendingD3d11Frame { + state: Arc>, + info: crate::GraphicsFrameInfo, +} + +#[cfg(target_os = "windows")] +impl crate::GraphicsFrame for PendingD3d11Frame { + fn info(&self) -> crate::GraphicsFrameInfo { + self.info + } + + fn record( + &self, + context: crate::GraphicsContext, + command: crate::GraphicsRecordCommand, + ) -> Result { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .record(context, command) + } +} + +impl MediaControl { + pub fn subscribe_recording( + &self, + ) -> Result<(MediaStreamConfig, EncodedRecordingReceiver), String> { + self.shared + .recording_tap + .subscribe() + .map(|receiver| (self.shared.stream, receiver)) + } + + pub fn unsubscribe_recording(&self) { + self.shared.recording_tap.unsubscribe(); + } + + pub fn update_cursor(&self, bytes: Vec) { + let _ = self.host_commands.send(HostCommand::Cursor(bytes)); + } + + pub fn stop(&self) { + if self.shared.stopped.swap(true, Ordering::AcqRel) { + return; + } + self.shared.video.close(); + self.shared.audio.close(); + self.shared.recording_tap.unsubscribe(); + self.shared.output.clear(); + let _ = self.host_commands.send(HostCommand::Stop); + } +} + +#[cfg(target_os = "windows")] +fn run_windows_video(shared: Arc, maximum_fps: u32) { + use opennow_streamer_platform_windows::{ + EncodedVideoFrame, PushOutcome as WindowsPushOutcome, VideoCodec, + }; + + let mut sample_clock = AdaptiveSampleClock::new(maximum_fps); + while let Some(frame) = shared.video.pop() { + if shared.paused.load(Ordering::Acquire) { + continue; + } + if shared.windows_bridge.use_software() { + shared.video_desynced.store(true, Ordering::Release); + match H264Decoder::new() { + Ok(decoder) => run_video_decoder_from(shared, decoder, Some(frame)), + Err(message) => { + let _ = shared.feedback.send(MediaFeedback::DecoderError { + codec: "h264", + message, + }); + } + } + return; + } + if (shared.video_desynced.load(Ordering::Acquire) + || shared.windows_bridge.keyframe_required()) + && !frame.keyframe + { + continue; + } + let Some(backend) = shared.windows_bridge.backend() else { + shared.video_desynced.store(true, Ordering::Release); + request_keyframe(&shared, &frame.mid, "D3D11 backend is unavailable"); + continue; + }; + shared.windows_bridge.set_last_video_mid(&frame.mid); + let timestamp_100ns = media_timestamp_100ns(frame.timestamp, frame.clock_rate_hz); + let duration_100ns = sample_clock.observe(timestamp_100ns); + let reset_decoder = frame.keyframe + && (shared.video_desynced.load(Ordering::Acquire) + || shared.windows_bridge.keyframe_required()); + match backend.submit_video(EncodedVideoFrame { + codec: match frame.codec { + MediaCodec::H264 => VideoCodec::H264, + MediaCodec::H265 => VideoCodec::H265, + MediaCodec::Av1 => VideoCodec::Av1, + _ => continue, + }, + data: frame.data.to_vec(), + timestamp_100ns, + duration_100ns, + key_frame: frame.keyframe, + reset_decoder, + }) { + Ok(WindowsPushOutcome::Queued) => { + report_video_frame_accepted(&shared, &frame); + if frame.keyframe { + shared.video_desynced.store(false, Ordering::Release); + shared.keyframe_requested.store(false, Ordering::Release); + shared.windows_bridge.accept_keyframe(); + } + } + Ok(WindowsPushOutcome::Paused) => {} + Ok(WindowsPushOutcome::DroppedOldest) => { + shared.video_desynced.store(true, Ordering::Release); + shared.windows_bridge.require_keyframe(); + let _ = shared.feedback.send(MediaFeedback::QueueDropped { + media: "d3d11-video", + count: 1, + }); + request_keyframe(&shared, &frame.mid, "D3D11 input queue overflow"); + } + Err(error) => { + shared.video_desynced.store(true, Ordering::Release); + shared.windows_bridge.require_keyframe(); + let _ = shared.feedback.send(MediaFeedback::DecoderError { + codec: match frame.codec { + MediaCodec::H264 => "h264", + MediaCodec::H265 => "h265", + MediaCodec::Av1 => "av1", + _ => "video", + }, + message: error.to_string(), + }); + request_keyframe(&shared, &frame.mid, "D3D11 decoder rejected an access unit"); + } + } + } +} + +#[cfg(any(target_os = "windows", test))] struct AdaptiveSampleClock { previous_timestamp_100ns: Option, nominal_duration_100ns: i64, @@ -1529,6 +2216,236 @@ fn run_linux_video(shared: Arc, host_commands: Sender) { + while let Some(frame) = shared.video.pop() { + if shared.paused.load(Ordering::Acquire) { + continue; + } + *shared + .linux_video_mid + .lock() + .unwrap_or_else(|error| error.into_inner()) = frame.mid.clone(); + let encoded = match opennow_streamer_platform_linux::EncodedVideoFrame::new( + Arc::clone(&frame.data), + media_timestamp_us(frame.timestamp, frame.clock_rate_hz), + frame.keyframe, + ) { + Ok(encoded) => encoded, + Err(error) => { + let _ = shared.feedback.send(MediaFeedback::DecoderError { + codec: shared.linux_codec.label(), + message: error.to_string(), + }); + request_linux_keyframe(&shared, "Linux decoder rejected encoded video framing"); + continue; + } + }; + let result = shared + .linux_session + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + .ok_or_else(|| "embedded Linux media session is unavailable".to_owned()) + .and_then(|session| { + session + .submit_video(encoded) + .map_err(|error| error.to_string()) + }); + match result { + Ok(opennow_streamer_platform_linux::PushOutcome::Queued) => { + report_video_frame_accepted(&shared, &frame); + if frame.keyframe { + shared.video_desynced.store(false, Ordering::Release); + shared.keyframe_requested.store(false, Ordering::Release); + } + } + Ok(opennow_streamer_platform_linux::PushOutcome::DroppedOldest) => { + shared.video_desynced.store(true, Ordering::Release); + request_linux_keyframe(&shared, "embedded Linux decoder queue overflow"); + } + Ok(opennow_streamer_platform_linux::PushOutcome::Paused) => {} + Err(message) => { + let _ = shared.feedback.send(MediaFeedback::DecoderError { + codec: shared.linux_codec.label(), + message, + }); + request_linux_keyframe(&shared, "embedded Linux video submission failed"); + } + } + } +} + +#[cfg(target_os = "linux")] +fn run_embedded_linux_audio(shared: Arc) { + while let Some(frame) = shared.audio.pop() { + if shared.paused.load(Ordering::Acquire) { + continue; + } + let MediaCodec::Opus { .. } = frame.codec else { + continue; + }; + let packet = match opennow_streamer_platform_linux::AudioPacket::new( + Arc::clone(&frame.data), + media_timestamp_us(frame.timestamp, frame.clock_rate_hz), + ) { + Ok(packet) => packet, + Err(error) => { + let _ = shared.feedback.send(MediaFeedback::DecoderError { + codec: "opus", + message: error.to_string(), + }); + continue; + } + }; + let result = shared + .linux_session + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + .ok_or_else(|| "embedded Linux media session is unavailable".to_owned()) + .and_then(|session| { + session + .submit_audio(packet) + .map_err(|error| error.to_string()) + }); + match result { + Ok(opennow_streamer_platform_linux::PushOutcome::DroppedOldest) => { + let _ = shared.feedback.send(MediaFeedback::QueueDropped { + media: "linux-audio", + count: 1, + }); + } + Ok(opennow_streamer_platform_linux::PushOutcome::Queued) + | Ok(opennow_streamer_platform_linux::PushOutcome::Paused) => {} + Err(message) => { + let _ = shared.feedback.send(MediaFeedback::DecoderError { + codec: "opus", + message, + }); + } + } + } +} + +#[cfg(target_os = "linux")] +fn run_embedded_linux_monitor( + shared: Arc, + publisher: crate::GraphicsFramePublisher, + producer: crate::LinuxGpuFrameProducer, +) { + use std::time::Duration; + + let mut playback_started = false; + while !shared.stopped.load(Ordering::Acquire) { + let (frames, events) = { + let session = shared + .linux_session + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(session) = session.as_ref() else { + return; + }; + let mut decoded = Vec::new(); + while let Some(frame) = session.try_recv_frame() { + decoded.push(frame); + } + let mut events = Vec::new(); + while let Some(event) = session.try_recv_event() { + events.push(event); + } + (decoded, events) + }; + if !shared.paused.load(Ordering::Acquire) { + for decoded in frames { + let Some(lease) = publisher.context() else { + continue; + }; + match producer + .frame(decoded) + .map_err(|error| error.to_string()) + .and_then(|frame| { + publisher + .publish(lease, Arc::new(frame)) + .map_err(|error| error.to_string()) + }) { + Ok(_) if !playback_started => { + playback_started = true; + let _ = shared.feedback.send(MediaFeedback::PlaybackStarted { + backend: "Linux decoder/embedded Vulkan", + }); + } + Ok(_) => {} + Err(message) => { + let _ = shared.feedback.send(MediaFeedback::OutputError { message }); + } + } + } + } + for event in events { + match event { + opennow_streamer_platform_linux::BackendEvent::DecoderChanged { + from, + to, + reason, + } => { + let _ = shared.feedback.send(MediaFeedback::BackendFallback { + from: linux_decoder_name(from), + to: linux_decoder_name(to), + reason, + }); + } + opennow_streamer_platform_linux::BackendEvent::NeedKeyframe => { + request_linux_keyframe(&shared, "Linux decoder requires a fresh keyframe"); + } + opennow_streamer_platform_linux::BackendEvent::QueueOverflow { media } => { + let _ = shared + .feedback + .send(MediaFeedback::QueueDropped { media, count: 1 }); + } + opennow_streamer_platform_linux::BackendEvent::DeviceLost { subsystem, reason } => { + let _ = shared.feedback.send(MediaFeedback::DeviceLost { + subsystem: linux_subsystem_name(subsystem), + recovered: false, + message: Some(reason), + }); + request_linux_keyframe(&shared, "Linux decoder device was lost"); + } + opennow_streamer_platform_linux::BackendEvent::Error(message) => { + let _ = shared.feedback.send(MediaFeedback::DecoderError { + codec: shared.linux_codec.label(), + message, + }); + shared.stopped.store(true, Ordering::Release); + shared.video.close(); + shared.audio.close(); + stop_linux_session(&shared); + return; + } + opennow_streamer_platform_linux::BackendEvent::StateChanged( + opennow_streamer_platform_linux::LifecycleState::Failed, + ) => { + let _ = shared.feedback.send(MediaFeedback::DecoderError { + codec: shared.linux_codec.label(), + message: "embedded Linux media session failed".to_owned(), + }); + shared.stopped.store(true, Ordering::Release); + shared.video.close(); + shared.audio.close(); + stop_linux_session(&shared); + return; + } + opennow_streamer_platform_linux::BackendEvent::StateChanged(_) + | opennow_streamer_platform_linux::BackendEvent::DecoderSelected(_) + | opennow_streamer_platform_linux::BackendEvent::AudioSelected(_) + | opennow_streamer_platform_linux::BackendEvent::FormatChanged(_) => {} + } + } + thread::sleep(Duration::from_millis(2)); + } + stop_linux_session(&shared); +} + #[cfg(target_os = "linux")] fn run_linux_monitor(shared: Arc, host_commands: Sender) { use std::time::Duration; diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/microphone.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/microphone.rs deleted file mode 100644 index 8f17a94c6..000000000 --- a/native/opennow-streamer/crates/opennow-streamer-platform/src/microphone.rs +++ /dev/null @@ -1,252 +0,0 @@ -use std::collections::VecDeque; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread::{self, JoinHandle}; - -use opus::{Application, Channels, Encoder}; -use sdl2::audio::{AudioCallback, AudioDevice, AudioSpecDesired}; - -use crate::queue::BoundedQueue; - -const SAMPLE_RATE: u32 = 48_000; -const FRAME_SAMPLES: usize = 960; -const PCM_QUEUE_CAPACITY: usize = 8; -const PACKET_QUEUE_CAPACITY: usize = 4; -const MAX_OPUS_PACKET_BYTES: usize = 4_000; - -#[derive(Debug, Clone)] -pub struct EncodedMicrophonePacket { - pub payload: Arc<[u8]>, - pub captured_at_us: u64, - pub audio_level_db: i8, - pub voice_activity: bool, -} - -#[derive(Debug, Default)] -pub struct EncodedMicrophoneQueue { - pending: Mutex>, - dropped: AtomicU64, -} - -impl EncodedMicrophoneQueue { - fn push(&self, packet: EncodedMicrophonePacket) { - let mut pending = self - .pending - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if pending.len() == PACKET_QUEUE_CAPACITY { - pending.pop_front(); - self.dropped.fetch_add(1, Ordering::Relaxed); - } - pending.push_back(packet); - } - - pub fn take(&self) -> Option { - self.pending - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pop_front() - } - - pub fn take_dropped(&self) -> u64 { - self.dropped.swap(0, Ordering::AcqRel) - } - - pub fn clear(&self) { - self.pending - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clear(); - self.dropped.store(0, Ordering::Release); - } -} - -struct CaptureCallback { - pcm: Arc>>, -} - -impl AudioCallback for CaptureCallback { - type Channel = i16; - - fn callback(&mut self, input: &mut [i16]) { - self.pcm.push(input.to_vec()); - } -} - -pub struct MicrophoneCapture { - device: AudioDevice, - pcm: Arc>>, - packets: Arc, - worker: Option>, - _sdl: sdl2::Sdl, -} - -impl MicrophoneCapture { - pub fn device_names() -> Result, String> { - let sdl = sdl2::init().map_err(|error| format!("SDL initialization failed: {error}"))?; - let audio = sdl - .audio() - .map_err(|error| format!("SDL audio initialization failed: {error}"))?; - let count = audio.num_audio_capture_devices().unwrap_or_default(); - Ok((0..count) - .filter_map(|index| audio.audio_capture_device_name(index).ok()) - .filter(|name| !name.trim().is_empty()) - .collect()) - } - - pub fn start(device_name: Option<&str>) -> Result { - let sdl = sdl2::init().map_err(|error| format!("SDL initialization failed: {error}"))?; - let audio = sdl - .audio() - .map_err(|error| format!("SDL audio initialization failed: {error}"))?; - let pcm = Arc::new(BoundedQueue::new(PCM_QUEUE_CAPACITY)); - let desired = AudioSpecDesired { - freq: Some(SAMPLE_RATE as i32), - channels: Some(1), - samples: Some(FRAME_SAMPLES as u16), - }; - let callback_pcm = Arc::clone(&pcm); - let device = audio - .open_capture( - device_name.filter(|value| !value.trim().is_empty()), - &desired, - move |_| CaptureCallback { pcm: callback_pcm }, - ) - .map_err(|error| format!("microphone capture could not start: {error}"))?; - if device.spec().freq != SAMPLE_RATE as i32 || device.spec().channels != 1 { - return Err(format!( - "microphone returned unsupported format: {} Hz, {} channels", - device.spec().freq, - device.spec().channels - )); - } - let packets = Arc::new(EncodedMicrophoneQueue::default()); - let worker_pcm = Arc::clone(&pcm); - let worker_packets = Arc::clone(&packets); - let worker = thread::Builder::new() - .name("opennow-microphone-opus".to_owned()) - .spawn(move || encode_worker(worker_pcm, worker_packets)) - .map_err(|error| format!("microphone encoder worker could not start: {error}"))?; - device.resume(); - Ok(Self { - device, - pcm, - packets, - worker: Some(worker), - _sdl: sdl, - }) - } - - pub fn packets(&self) -> Arc { - Arc::clone(&self.packets) - } - - pub fn set_enabled(&self, enabled: bool) { - if enabled { - self.device.resume(); - } else { - self.device.pause(); - self.packets.clear(); - } - } -} - -impl Drop for MicrophoneCapture { - fn drop(&mut self) { - self.device.pause(); - self.pcm.close(); - if let Some(worker) = self.worker.take() { - let _ = worker.join(); - } - self.packets.clear(); - } -} - -fn encode_worker(pcm: Arc>>, packets: Arc) { - let Ok(mut encoder) = Encoder::new(SAMPLE_RATE, Channels::Mono, Application::Voip) else { - return; - }; - let mut accumulated = VecDeque::with_capacity(FRAME_SAMPLES * 2); - let mut output = vec![0_u8; MAX_OPUS_PACKET_BYTES]; - let mut sample_cursor = 0_u64; - while let Some(samples) = pcm.pop() { - accumulated.extend(samples); - while accumulated.len() >= FRAME_SAMPLES { - let frame = accumulated.drain(..FRAME_SAMPLES).collect::>(); - let Ok(length) = encoder.encode(&frame, &mut output) else { - continue; - }; - let (audio_level_db, voice_activity) = audio_level(&frame); - packets.push(EncodedMicrophonePacket { - payload: Arc::from(&output[..length]), - captured_at_us: sample_cursor.saturating_mul(1_000_000) / u64::from(SAMPLE_RATE), - audio_level_db, - voice_activity, - }); - sample_cursor = sample_cursor.saturating_add(FRAME_SAMPLES as u64); - } - } -} - -fn audio_level(samples: &[i16]) -> (i8, bool) { - let mean_square = samples - .iter() - .map(|sample| { - let normalized = f64::from(*sample) / 32768.0; - normalized * normalized - }) - .sum::() - / samples.len().max(1) as f64; - let db = if mean_square <= f64::EPSILON { - -127.0 - } else { - 20.0 * mean_square.sqrt().log10() - }; - let bounded = db.round().clamp(-127.0, 0.0) as i8; - (bounded, bounded > -60) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn microphone_queue_is_bounded_and_levels_are_stable() { - let queue = EncodedMicrophoneQueue::default(); - for index in 0..6_u64 { - queue.push(EncodedMicrophonePacket { - payload: Arc::from([index as u8]), - captured_at_us: index, - audio_level_db: -30, - voice_activity: true, - }); - } - assert_eq!(queue.take_dropped(), 2); - assert_eq!(queue.take().unwrap().captured_at_us, 2); - assert_eq!(audio_level(&[0; FRAME_SAMPLES]), (-127, false)); - assert!(audio_level(&[10_000; FRAME_SAMPLES]).1); - } - - #[test] - fn microphone_pcm_encodes_to_opus() { - let pcm = Arc::new(BoundedQueue::new(2)); - let packets = Arc::new(EncodedMicrophoneQueue::default()); - let worker_pcm = Arc::clone(&pcm); - let worker_packets = Arc::clone(&packets); - let worker = thread::spawn(move || encode_worker(worker_pcm, worker_packets)); - pcm.push(vec![0_i16; FRAME_SAMPLES]); - let packet = (0..100) - .find_map(|_| { - let packet = packets.take(); - if packet.is_none() { - thread::sleep(std::time::Duration::from_millis(5)); - } - packet - }) - .expect("encoded microphone packet"); - pcm.close(); - worker.join().expect("microphone worker"); - assert!(!packet.payload.is_empty()); - assert_eq!(packet.captured_at_us, 0); - } -} diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/output.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/output.rs index 18f18bce0..f83637ba6 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform/src/output.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform/src/output.rs @@ -143,6 +143,10 @@ impl WindowsBridge { impl OutputBuffers { pub(crate) fn new() -> Self { + Self::with_captured_input(Arc::new(CapturedInputQueue::default())) + } + + pub(crate) fn with_captured_input(captured_input: Arc) -> Self { Self { video: Mutex::new(None), #[cfg(target_os = "linux")] @@ -154,7 +158,7 @@ impl OutputBuffers { * AUDIO_CHANNELS as usize * MAX_AUDIO_LATENCY_MS / 1_000, - captured_input: Arc::new(CapturedInputQueue::default()), + captured_input, } } @@ -276,6 +280,57 @@ impl OutputBuffers { } } +#[cfg(target_os = "windows")] +pub(crate) struct HeadlessAudioOutput { + device: AudioDevice, + output: Arc, + _sdl: sdl2::Sdl, +} + +#[cfg(target_os = "windows")] +impl HeadlessAudioOutput { + pub(crate) fn start(output: Arc) -> Result { + let sdl = sdl2::init().map_err(|error| format!("SDL initialization failed: {error}"))?; + let audio_subsystem = sdl + .audio() + .map_err(|error| format!("SDL audio initialization failed: {error}"))?; + let desired = AudioSpecDesired { + freq: Some(AUDIO_SAMPLE_RATE), + channels: Some(AUDIO_CHANNELS), + samples: Some(AUDIO_BUFFER_FRAMES), + }; + let callback_output = Arc::clone(&output); + let device = audio_subsystem + .open_playback(None, &desired, move |_| StreamAudioCallback { + output: callback_output, + }) + .map_err(|error| format!("native audio output creation failed: {error}"))?; + device.resume(); + Ok(Self { + device, + output, + _sdl: sdl, + }) + } + + pub(crate) fn set_paused(&self, paused: bool) { + if paused { + self.device.pause(); + self.output.clear(); + } else { + self.device.resume(); + } + } +} + +#[cfg(target_os = "windows")] +impl Drop for HeadlessAudioOutput { + fn drop(&mut self) { + self.device.pause(); + self.output.clear(); + } +} + #[cfg(target_os = "linux")] pub(crate) struct LinuxHardwareOutput { // These fields own resources derived from the SDL Wayland/X11 display. diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/runtime.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/runtime.rs index 9eed9f678..56e641a64 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform/src/runtime.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform/src/runtime.rs @@ -7,6 +7,7 @@ use std::time::Duration; use opennow_streamer_protocol::RenderSurface; +use crate::GraphicsFramePublisher; use crate::media::{MediaFeedback, MediaSession, MediaStreamConfig}; #[cfg(target_os = "windows")] use crate::output::WindowsBridge; @@ -87,14 +88,63 @@ pub struct MediaRuntime { linux_selection: LinuxVideoSelection, #[cfg(target_os = "linux")] linux_software_fallback: Arc, + mode: MediaRuntimeMode, +} + +#[derive(Clone)] +enum MediaRuntimeMode { + Standalone, + Embedded(GraphicsFramePublisher), + #[cfg(feature = "test-runtime")] + Test, } impl MediaRuntime { + pub const fn is_embedded(&self) -> bool { + matches!(self.mode, MediaRuntimeMode::Embedded(_)) + } + + pub fn captured_input(&self) -> Arc { + self.output.captured_input() + } + pub fn start( &self, feedback: Sender, stream: MediaStreamConfig, ) -> Result { + if let MediaRuntimeMode::Embedded(frames) = &self.mode { + let session = MediaSession::spawn_embedded( + Arc::clone(&self.output), + feedback, + self.commands.clone(), + stream, + frames.clone(), + #[cfg(target_os = "linux")] + self.linux_selection.clone(), + )?; + if self.paused.load(Ordering::Acquire) { + session.set_paused(true); + } + return Ok(session); + } + #[cfg(feature = "test-runtime")] + if matches!(self.mode, MediaRuntimeMode::Test) { + let session = MediaSession::spawn_test( + Arc::clone(&self.output), + feedback, + self.commands.clone(), + stream, + #[cfg(target_os = "windows")] + Arc::clone(&self.windows_bridge), + #[cfg(target_os = "linux")] + Arc::clone(&self.linux_software_fallback), + )?; + if self.paused.load(Ordering::Acquire) { + session.set_paused(true); + } + return Ok(session); + } let (reply, response) = mpsc::channel(); self.commands .send(HostCommand::Start { @@ -145,6 +195,9 @@ impl MediaRuntime { } pub fn update_surface(&self, surface: RenderSurface) -> Result<(), String> { + if !matches!(self.mode, MediaRuntimeMode::Standalone) { + return Ok(()); + } let (reply, response) = mpsc::channel(); self.commands .send(HostCommand::Surface { surface, reply }) @@ -156,6 +209,9 @@ impl MediaRuntime { pub fn set_paused(&self, paused: bool) -> Result<(), String> { self.paused.store(paused, Ordering::Release); + if !matches!(self.mode, MediaRuntimeMode::Standalone) { + return Ok(()); + } let (reply, response) = mpsc::channel(); self.commands .send(HostCommand::Pause { @@ -169,6 +225,9 @@ impl MediaRuntime { } pub fn control(&self, control: MediaRuntimeControl) -> Result<(), String> { + if !matches!(self.mode, MediaRuntimeMode::Standalone) { + return Ok(()); + } let (reply, response) = mpsc::channel(); self.commands .send(HostCommand::Control { control, reply }) @@ -751,10 +810,138 @@ pub fn create_runtime() -> Result<(MainThreadHost, MediaRuntime), String> { linux_selection, #[cfg(target_os = "linux")] linux_software_fallback, + mode: MediaRuntimeMode::Standalone, }, )) } +pub fn create_embedded_runtime(frames: GraphicsFramePublisher) -> MediaRuntime { + create_embedded_runtime_with_input(frames, Arc::new(crate::CapturedInputQueue::default()), None) +} + +pub fn create_embedded_runtime_with_input( + frames: GraphicsFramePublisher, + captured_input: Arc, + cursor_update: Option) + Send + Sync>>, +) -> MediaRuntime { + let (commands, receiver) = mpsc::channel(); + if let Some(cursor_update) = cursor_update { + let _ = std::thread::Builder::new() + .name("opennow-embedded-cursor".to_owned()) + .spawn(move || { + while let Ok(command) = receiver.recv() { + match command { + HostCommand::Cursor(bytes) => cursor_update(bytes), + HostCommand::Shutdown => break, + _ => {} + } + } + }); + } else { + drop(receiver); + } + #[cfg(target_os = "linux")] + let linux_selection = crate::linux_backend::select_video_path(); + MediaRuntime { + commands, + output: Arc::new(OutputBuffers::with_captured_input(captured_input)), + paused: Arc::new(AtomicBool::new(false)), + use_hardware: Arc::new(AtomicBool::new(true)), + #[cfg(target_os = "windows")] + windows_bridge: Arc::new(WindowsBridge::new()), + #[cfg(target_os = "linux")] + linux_selection, + #[cfg(target_os = "linux")] + linux_software_fallback: Arc::new(AtomicBool::new(false)), + mode: MediaRuntimeMode::Embedded(frames), + } +} + +#[cfg(feature = "test-runtime")] +pub struct TestMediaRuntimeHost { + worker: Option>, +} + +#[cfg(feature = "test-runtime")] +impl TestMediaRuntimeHost { + pub fn join(mut self) -> std::thread::Result<()> { + self.worker.take().expect("test media worker").join() + } +} + +#[cfg(feature = "test-runtime")] +pub fn create_test_runtime() -> (TestMediaRuntimeHost, MediaRuntime) { + let use_macos_hardware = Arc::new(AtomicBool::new(false)); + #[cfg(target_os = "linux")] + let linux_selection = crate::linux_backend::select_video_path(); + #[cfg(target_os = "linux")] + let linux_software_fallback = Arc::new(AtomicBool::new(matches!( + linux_selection.path, + LinuxVideoPath::Software + ))); + let (commands, receiver) = mpsc::channel(); + let output = Arc::new(OutputBuffers::new()); + let paused = Arc::new(AtomicBool::new(false)); + #[cfg(target_os = "windows")] + let windows_bridge = Arc::new(WindowsBridge::new()); + let worker = std::thread::Builder::new() + .name("opennow-test-media-runtime".to_owned()) + .spawn(move || { + while let Ok(command) = receiver.recv() { + match command { + HostCommand::Start { reply, .. } => { + let _ = reply.send(Err( + "test media runtime does not start media sessions".to_owned() + )); + } + HostCommand::Pause { reply, .. } => { + if let Some(reply) = reply { + let _ = reply.send(Ok(())); + } + } + HostCommand::Surface { reply, .. } | HostCommand::Control { reply, .. } => { + let _ = reply.send(Ok(())); + } + HostCommand::Cursor(_) | HostCommand::Stop => {} + #[cfg(target_os = "macos")] + HostCommand::ConfigureMacH264 { reply, .. } => { + let _ = reply.send(Err("test media runtime has no decoder".to_owned())); + } + #[cfg(target_os = "macos")] + HostCommand::ConfigureMacH265 { reply, .. } => { + let _ = reply.send(Err("test media runtime has no decoder".to_owned())); + } + #[cfg(target_os = "macos")] + HostCommand::ConfigureMacAv1 { reply, .. } => { + let _ = reply.send(Err("test media runtime has no decoder".to_owned())); + } + #[cfg(target_os = "linux")] + HostCommand::FallbackLinux { .. } => {} + HostCommand::Shutdown => break, + } + } + }) + .expect("test media runtime worker"); + ( + TestMediaRuntimeHost { + worker: Some(worker), + }, + MediaRuntime { + commands, + output, + paused, + use_hardware: use_macos_hardware, + #[cfg(target_os = "windows")] + windows_bridge, + #[cfg(target_os = "linux")] + linux_selection, + #[cfg(target_os = "linux")] + linux_software_fallback, + mode: MediaRuntimeMode::Test, + }, + ) +} + fn initialize_output( output: Arc, surface: Option<&RenderSurface>, @@ -871,4 +1058,40 @@ mod tests { assert!(backend_preference_allows_value(Some("d3d12"), "d3d12")); assert!(!backend_preference_allows_value(Some("d3d11"), "d3d12")); } + + #[cfg(feature = "test-runtime")] + #[test] + fn embedded_style_start_creates_a_live_media_consumer_without_a_render_surface() { + use std::sync::Arc; + use std::sync::mpsc; + use std::time::Duration; + + use crate::{EncodedFrame, MediaCodec, MediaFeedback, MediaStreamConfig, PushOutcome}; + + let (host, runtime) = super::create_test_runtime(); + let (feedback, received) = mpsc::channel(); + let session = runtime + .start(feedback, MediaStreamConfig::default()) + .expect("headless media session starts without a RenderSurface"); + assert_eq!( + session.sink().push(EncodedFrame { + mid: "video".to_owned(), + codec: MediaCodec::H264, + data: Arc::from([0_u8, 0, 0, 1, 0x65]), + timestamp: 90_000, + clock_rate_hz: 90_000, + keyframe: true, + contiguous: true, + }), + PushOutcome::Queued + ); + assert!(matches!( + received.recv_timeout(Duration::from_secs(2)), + Ok(MediaFeedback::VideoFrameAccepted { .. }) + )); + + session.stop(); + runtime.shutdown(); + host.join().expect("test media runtime"); + } } diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/windows_graphics.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/windows_graphics.rs new file mode 100644 index 000000000..11c1ec99d --- /dev/null +++ b/native/opennow-streamer/crates/opennow-streamer-platform/src/windows_graphics.rs @@ -0,0 +1,51 @@ +use opennow_streamer_platform_windows::{AdoptedD3d11Context, D3d11Frame}; + +use crate::{ + GraphicsApi, GraphicsContext, GraphicsFrame, GraphicsFrameInfo, GraphicsRecordCommand, + GraphicsRecordedFrame, +}; + +impl GraphicsFrame for D3d11Frame { + fn info(&self) -> GraphicsFrameInfo { + GraphicsFrameInfo { + width: self.width(), + height: self.height(), + sequence: self.sequence(), + presentation_time_ns: self.presentation_time_ns(), + } + } + + fn record( + &self, + context: GraphicsContext, + command: GraphicsRecordCommand, + ) -> Result { + if context.api != GraphicsApi::D3d11 { + return Err("a Windows decoded frame requires a D3D11 graphics context".to_owned()); + } + if command.command_buffer != context.queue { + return Err( + "D3D11 record command does not use Qt's adopted immediate context".to_owned(), + ); + } + let frame = unsafe { + self.record( + AdoptedD3d11Context { + device: context.device as *mut std::ffi::c_void, + immediate_context: context.queue as *mut std::ffi::c_void, + }, + command.frame_slot, + ) + } + .map_err(|error| error.to_string())?; + Ok(GraphicsRecordedFrame { + resource: frame.texture as usize as u64, + resource_view: 0, + width: frame.width, + height: frame.height, + frame_slot: frame.frame_slot, + generation: frame.generation, + presentation_time_ns: frame.presentation_time_ns, + }) + } +} diff --git a/native/opennow-streamer/crates/opennow-streamer-protocol/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-protocol/src/lib.rs index 4e0f87e26..9683a7526 100644 --- a/native/opennow-streamer/crates/opennow-streamer-protocol/src/lib.rs +++ b/native/opennow-streamer/crates/opennow-streamer-protocol/src/lib.rs @@ -14,12 +14,6 @@ pub struct Command { #[serde(default)] pub context: Option, #[serde(default)] - pub sdp: Option, - #[serde(default)] - pub candidate: Option, - #[serde(default)] - pub input: Option, - #[serde(default)] pub paused: Option, #[serde(default)] pub surface: Option, @@ -39,27 +33,6 @@ pub struct Command { pub output_path: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct IceCandidate { - pub candidate: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sdp_mid: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sdp_m_line_index: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub username_fragment: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NativeInput { - #[serde(default)] - pub payload_base64: String, - #[serde(default)] - pub partially_reliable: bool, -} - #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RenderSurface { @@ -119,8 +92,6 @@ pub struct Session { #[serde(default, skip_serializing_if = "Option::is_none")] pub sub_session_id: Option, pub server_ip: String, - #[serde(default)] - pub ice_servers: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub media_connection_info: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -146,18 +117,6 @@ pub struct ConnectionInfo { pub extra: Map, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct IceServer { - pub urls: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub username: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential: Option, - #[serde(flatten)] - pub extra: Map, -} - #[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct MediaConnectionInfo { @@ -174,11 +133,6 @@ pub struct MediaConnectionInfo { pub struct Capabilities { pub protocol_version: u64, pub backend: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - pub fallback_reason: Option<&'static str>, - pub supports_offer_answer: bool, - pub supports_remote_ice: bool, - pub supports_local_ice: bool, pub supports_input: bool, pub supports_video_decode: bool, pub supports_video_present: bool, @@ -247,18 +201,6 @@ mod tests { assert!(command.context.is_some()); } - #[test] - fn serializes_candidate_for_app_contract() { - let candidate = IceCandidate { - candidate: "candidate:1 1 udp 1 127.0.0.1 5000 typ host".to_owned(), - sdp_mid: Some("0".to_owned()), - sdp_m_line_index: Some(0), - username_fragment: None, - }; - let value = serde_json::to_value(candidate).expect("candidate"); - assert_eq!(value["sdpMLineIndex"], 0); - } - #[test] fn unsolicited_errors_do_not_serialize_a_null_request_id() { let value = error(None, "invalid-command", "bad JSON"); diff --git a/native/opennow-streamer/crates/opennow-streamer-transport/src/input.rs b/native/opennow-streamer/crates/opennow-streamer-transport/src/input.rs deleted file mode 100644 index b5e7830ed..000000000 --- a/native/opennow-streamer/crates/opennow-streamer-transport/src/input.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::time::{Duration, Instant}; - -use str0m::Rtc; -use str0m::channel::{ChannelConfig, ChannelId, Reliability}; - -pub(crate) const INPUT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2); - -const RELIABLE_INPUT_LABEL: &str = "input_channel_v1"; -const PARTIAL_INPUT_LABEL: &str = "input_channel_partially_reliable"; -const STATS_LABEL: &str = "stats_channel"; -const INPUT_HEARTBEAT: &[u8] = &[2, 0, 0, 0]; - -#[derive(Debug, Clone, Copy)] -pub(crate) struct InputChannels { - pub(crate) reliable: ChannelId, - pub(crate) partial: ChannelId, - pub(crate) stats: ChannelId, -} - -impl InputChannels { - pub(crate) fn create(rtc: &mut Rtc, partial_reliable_lifetime_ms: u16) -> Self { - let stats = rtc.direct_api().create_data_channel(ChannelConfig { - label: STATS_LABEL.to_owned(), - ordered: false, - reliability: Reliability::MaxRetransmits { retransmits: 0 }, - ..Default::default() - }); - let reliable = rtc.direct_api().create_data_channel(ChannelConfig { - label: RELIABLE_INPUT_LABEL.to_owned(), - ..Default::default() - }); - let partial = rtc.direct_api().create_data_channel(ChannelConfig { - label: PARTIAL_INPUT_LABEL.to_owned(), - ordered: false, - reliability: Reliability::MaxPacketLifetime { - lifetime: partial_reliable_lifetime_ms, - }, - ..Default::default() - }); - Self { - reliable, - partial, - stats, - } - } - - pub(crate) fn send(self, rtc: &mut Rtc, bytes: &[u8], partially_reliable: bool) -> bool { - let id = if partially_reliable { - self.partial - } else { - self.reliable - }; - rtc.channel(id) - .is_some_and(|mut channel| channel.write(true, bytes).unwrap_or(false)) - } - - pub(crate) fn send_heartbeat(self, rtc: &mut Rtc) -> bool { - self.send(rtc, INPUT_HEARTBEAT, false) - } -} - -#[derive(Debug, Default)] -pub(crate) struct InputChannelState { - reliable_open: bool, - partial_open: bool, - negotiated_version: Option, - ready_reported: bool, -} - -impl InputChannelState { - pub(crate) fn channel_opened(&mut self, channels: InputChannels, id: ChannelId) -> Option { - if id == channels.reliable { - self.reliable_open = true; - } else if id == channels.partial { - self.partial_open = true; - } - self.take_new_ready_version() - } - - pub(crate) fn channel_data( - &mut self, - channels: InputChannels, - id: ChannelId, - bytes: &[u8], - ) -> Option { - if id == channels.stats { - return None; - } - if id == channels.reliable - && let Some(version) = input_protocol_version(bytes) - { - self.negotiated_version = Some(version); - } - self.take_new_ready_version() - } - - pub(crate) fn is_ready(&self) -> bool { - self.reliable_open && self.partial_open && self.negotiated_version.is_some() - } - - pub(crate) fn channel_closed(&mut self, channels: InputChannels, id: ChannelId) -> bool { - let was_ready = self.is_ready(); - if id == channels.reliable { - self.reliable_open = false; - self.negotiated_version = None; - } else if id == channels.partial { - self.partial_open = false; - } else { - return false; - } - self.ready_reported = false; - was_ready - } - - fn take_new_ready_version(&mut self) -> Option { - if self.ready_reported || !self.is_ready() { - return None; - } - self.ready_reported = true; - self.negotiated_version - } -} - -pub(crate) fn next_input_heartbeat(now: Instant) -> Instant { - now + INPUT_HEARTBEAT_INTERVAL -} - -fn input_protocol_version(bytes: &[u8]) -> Option { - if bytes.len() < 2 { - return None; - } - let first = u16::from_le_bytes([bytes[0], bytes[1]]); - if first == 526 { - return Some(if bytes.len() >= 4 { - u16::from_le_bytes([bytes[2], bytes[3]]) - } else { - 2 - }); - } - (bytes[0] == 0x0e).then_some(first) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn channels() -> InputChannels { - let mut rtc = Rtc::new(Instant::now()); - InputChannels::create(&mut rtc, 300) - } - - #[test] - fn parses_both_input_handshake_layouts() { - assert_eq!(input_protocol_version(&[0x0e, 0x02, 0x03, 0x00]), Some(3)); - assert_eq!(input_protocol_version(&[0x0e, 0x03]), Some(0x030e)); - assert_eq!(input_protocol_version(&[1]), None); - } - - #[test] - fn input_is_ready_only_after_both_channels_and_handshake() { - let channels = channels(); - let mut state = InputChannelState::default(); - - assert_eq!(state.channel_opened(channels, channels.reliable), None); - assert_eq!( - state.channel_data(channels, channels.reliable, &[0x0e, 0x02, 0x03, 0x00]), - None - ); - assert!(!state.is_ready()); - assert_eq!(state.channel_opened(channels, channels.partial), Some(3)); - assert!(state.is_ready()); - assert!(state.channel_closed(channels, channels.partial)); - assert!(!state.is_ready()); - assert!(!state.channel_closed(channels, channels.stats)); - } -} diff --git a/native/opennow-streamer/crates/opennow-streamer-transport/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-transport/src/lib.rs index 8c69beea1..e9ef6b092 100644 --- a/native/opennow-streamer/crates/opennow-streamer-transport/src/lib.rs +++ b/native/opennow-streamer/crates/opennow-streamer-transport/src/lib.rs @@ -1,66 +1,28 @@ -use std::io::ErrorKind; -use std::net::{IpAddr, SocketAddr, ToSocketAddrs, UdpSocket}; -use std::ops::Range; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError, TrySendError}; -use std::sync::{Arc, Once}; -use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; +use std::sync::Arc; +use std::sync::Once; +use std::sync::mpsc::{SyncSender, TrySendError}; -use opennow_streamer_protocol::{IceCandidate, IceServer, MediaConnectionInfo, Session}; -use str0m::change::SdpOffer; use str0m::crypto::from_feature_flags; -use str0m::format::Codec; -use str0m::media::{KeyframeRequestKind, MediaKind, MediaTime, Mid, Pt}; -use str0m::net::{Protocol, Receive}; -use str0m::{Candidate, Event, IceConnectionState, Input, Output, Rtc, RtcConfig}; use thiserror::Error; -mod input; pub mod nvst; mod nvst_control; mod nvst_input; -use input::{InputChannelState, InputChannels, next_input_heartbeat}; - pub use nvst::{ BoundedFrameQueue, EncodedVideoAccessUnit, NvstBundleIdentity, NvstConfigError, NvstDropReason, - NvstFallbackReason, NvstReceiveEvent, NvstReceiverState, NvstRecovery, NvstSrtpProfile, - NvstUdpReceiverControl, NvstUdpReceiverError, NvstUdpReceiverSession, NvstUnsupportedFeature, - NvstVideoCodec, NvstVideoConfig, NvstVideoReceiver, PreferredVideoTransport, - ReservedNvstBundle, SharedNvstFeedback, advertised_nvst_ipv4, parse_nvst_video_handoff, - reserve_nvst_mjolnir_udp_socket, reserve_nvst_udp_socket, select_preferred_video_transport, - spawn_nvst_mjolnir_receiver, spawn_nvst_udp_receiver, spawn_nvst_udp_receiver_with_socket, + NvstReceiveEvent, NvstReceiverState, NvstRecovery, NvstSrtpProfile, NvstUdpReceiverControl, + NvstUdpReceiverError, NvstUdpReceiverSession, NvstUnsupportedFeature, NvstVideoCodec, + NvstVideoConfig, NvstVideoReceiver, ReservedNvstBundle, SharedNvstFeedback, + advertised_nvst_ipv4, parse_nvst_video_handoff, reserve_nvst_mjolnir_udp_socket, + reserve_nvst_udp_socket, spawn_nvst_mjolnir_receiver, spawn_nvst_udp_receiver, + spawn_nvst_udp_receiver_with_socket, }; static INSTALL_CRYPTO: Once = Once::new(); #[derive(Debug, Error)] pub enum TransportError { - #[error("invalid server endpoint: {0}")] - InvalidServerEndpoint(String), - #[error("failed to resolve server endpoint {endpoint}: {source}")] - ResolveServerEndpoint { - endpoint: String, - #[source] - source: std::io::Error, - }, - #[error("invalid WebRTC media endpoint: {0}")] - InvalidMediaEndpoint(String), - #[error("failed to resolve WebRTC media endpoint {endpoint}: {source}")] - ResolveMediaEndpoint { - endpoint: String, - #[source] - source: std::io::Error, - }, - #[error("failed to bind media UDP socket: {0}")] - Bind(#[source] std::io::Error), - #[error("invalid WebRTC offer: {0}")] - Offer(String), - #[error("failed to configure local ICE candidate: {0}")] - LocalCandidate(String), - #[error("invalid remote ICE candidate: {0}")] - RemoteCandidate(String), #[error("input channel is not ready")] InputNotReady, #[error("encoded media consumer is no longer running")] @@ -69,45 +31,19 @@ pub enum TransportError { MediaConsumerBackpressured, #[error("transport worker is no longer running")] Closed, - #[error("microphone media is not negotiated")] - MicrophoneUnavailable, - #[error("microphone media queue is backpressured")] - MicrophoneBackpressured, } impl TransportError { pub const fn code(&self) -> &'static str { match self { - Self::InvalidServerEndpoint(_) | Self::ResolveServerEndpoint { .. } => { - "invalid-server-endpoint" - } - Self::InvalidMediaEndpoint(_) | Self::ResolveMediaEndpoint { .. } => { - "invalid-media-endpoint" - } - Self::Bind(_) | Self::LocalCandidate(_) => "local-transport-failed", - Self::Offer(_) => "invalid-offer", - Self::RemoteCandidate(_) => "invalid-remote-candidate", Self::InputNotReady => "input-not-ready", Self::MediaConsumerClosed => "media-consumer-closed", Self::MediaConsumerBackpressured => "media-consumer-backpressured", Self::Closed => "transport-closed", - Self::MicrophoneUnavailable => "microphone-unavailable", - Self::MicrophoneBackpressured => "microphone-backpressured", } } } -#[derive(Debug)] -pub enum TransportEvent { - Connected, - Disconnected(String), - InputReady(u16), - InputUnavailable(String), - MicrophoneReady, - MicrophoneUnavailable(String), - Log(String), -} - #[derive(Debug, Clone)] pub struct EncodedMediaFrame { pub mid: String, @@ -123,26 +59,6 @@ pub struct EncodedMediaFrame { pub type MediaConsumer = SyncSender; -/// The single video codec the local decoder has been configured to consume. -/// -/// WebRTC offers can contain several GFN codecs. Advertising more than this -/// one would allow the peer to select a format that does not match the active -/// native decoder. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NegotiatedVideoCodec { - H264, - H265, - Av1, -} - -#[derive(Debug, Clone)] -pub struct EncodedMicrophoneFrame { - pub payload: Arc<[u8]>, - pub captured_at_us: u64, - pub audio_level_db: i8, - pub voice_activity: bool, -} - pub fn install_crypto() { INSTALL_CRYPTO.call_once(|| from_feature_flags().install_process_default()); } @@ -157,1030 +73,41 @@ fn deliver_media_frame( }) } -enum TransportCommand { - AddRemoteCandidate(Candidate), - SendInput { - bytes: Vec, - partially_reliable: bool, - }, - RequestKeyframe { - mid: String, - }, - Stop, -} - -pub struct NegotiatedTransport { - pub answer_sdp: String, - pub local_candidate: IceCandidate, - pub session: TransportSession, -} - -pub struct TransportSession { - commands: Sender, - join: Option>, - media_endpoint: Option, - input_ready: Arc, - microphone: SyncSender, - microphone_ready: Arc, -} - -#[derive(Clone)] -pub struct TransportControl { - commands: Sender, - input_ready: Arc, - microphone: SyncSender, - microphone_ready: Arc, -} - -impl TransportSession { - pub fn control(&self) -> TransportControl { - TransportControl { - commands: self.commands.clone(), - input_ready: Arc::clone(&self.input_ready), - microphone: self.microphone.clone(), - microphone_ready: Arc::clone(&self.microphone_ready), - } - } - - pub fn add_remote_candidate(&self, candidate: &IceCandidate) -> Result<(), TransportError> { - let candidate = normalize_remote_candidate(&candidate.candidate, self.media_endpoint); - let candidate = candidate.strip_prefix("a=").unwrap_or(&candidate); - let candidate = Candidate::from_sdp_string(candidate) - .map_err(|error| TransportError::RemoteCandidate(error.to_string()))?; - self.commands - .send(TransportCommand::AddRemoteCandidate(candidate)) - .map_err(|_| TransportError::Closed) - } - - pub fn send_input( - &self, - bytes: Vec, - partially_reliable: bool, - ) -> Result<(), TransportError> { - if !self.input_ready.load(Ordering::Acquire) { - return Err(TransportError::InputNotReady); - } - self.commands - .send(TransportCommand::SendInput { - bytes, - partially_reliable, - }) - .map_err(|_| TransportError::Closed) - } - - pub fn stop(mut self) { - let _ = self.commands.send(TransportCommand::Stop); - if let Some(join) = self.join.take() { - let _ = join.join(); - } - } -} - -impl TransportControl { - pub fn request_keyframe(&self, mid: impl Into) -> Result<(), TransportError> { - self.commands - .send(TransportCommand::RequestKeyframe { mid: mid.into() }) - .map_err(|_| TransportError::Closed) - } - - pub fn send_input( - &self, - bytes: Vec, - partially_reliable: bool, - ) -> Result<(), TransportError> { - if !self.input_ready.load(Ordering::Acquire) { - return Err(TransportError::InputNotReady); - } - self.commands - .send(TransportCommand::SendInput { - bytes, - partially_reliable, - }) - .map_err(|_| TransportError::Closed) - } - - pub fn send_microphone(&self, frame: EncodedMicrophoneFrame) -> Result<(), TransportError> { - if !self.microphone_ready.load(Ordering::Acquire) { - return Err(TransportError::MicrophoneUnavailable); - } - self.microphone - .try_send(frame) - .map_err(|error| match error { - TrySendError::Full(_) => TransportError::MicrophoneBackpressured, - TrySendError::Disconnected(_) => TransportError::Closed, - }) - } -} - -impl Drop for TransportSession { - fn drop(&mut self) { - let _ = self.commands.send(TransportCommand::Stop); - } -} - -pub fn negotiate( - offer_sdp: &str, - session: &Session, - video_codec: NegotiatedVideoCodec, - partial_reliable_lifetime_ms: u16, - events: Sender, - media_consumer: MediaConsumer, -) -> Result { - install_crypto(); - - if let Some(schemes) = configured_ice_schemes(&session.ice_servers) { - let _ = events.send(TransportEvent::Log(format!( - "Configured ICE services ({schemes}) are not gathered locally; continuing with a direct host candidate for the ICE-lite GFN peer" - ))); - } - let normalized_offer = normalize_offer_endpoints(offer_sdp, session)?; - let server_ip = match normalized_offer.media_endpoint { - Some(endpoint) => endpoint.ip(), - None => resolve_server_endpoint(&session.server_ip)?, - }; - let socket = bind_routed_socket(server_ip).map_err(TransportError::Bind)?; - let local_addr = socket.local_addr().map_err(TransportError::Bind)?; - let local_candidate = Candidate::host(local_addr, "udp") - .map_err(|error| TransportError::LocalCandidate(error.to_string()))?; - - // Advertise exactly the format selected by CloudMatch and configured in - // the native decoder. Offering fallback video formats here is unsafe: the - // peer may choose one after the decoder has already been created. - let builder = RtcConfig::new().clear_codecs().enable_opus(true); - let builder = match video_codec { - NegotiatedVideoCodec::H264 => builder.enable_h264(true), - NegotiatedVideoCodec::H265 => builder.enable_h265(true), - NegotiatedVideoCodec::Av1 => builder.enable_av1(true), - }; - let mut rtc = builder.build(Instant::now()); - rtc.add_local_candidate(local_candidate.clone()); - let offer = SdpOffer::from_sdp_string(&normalized_offer.sdp) - .map_err(|error| TransportError::Offer(error.to_string()))?; - let answer = rtc - .sdp_api() - .accept_offer(offer) - .map_err(|error| TransportError::Offer(error.to_string()))?; - - let channels = InputChannels::create(&mut rtc, partial_reliable_lifetime_ms); - - let answer_sdp = answer.to_sdp_string(); - let candidate_text = local_candidate.to_sdp_string(); - let (command_tx, command_rx) = mpsc::channel(); - let (microphone_tx, microphone_rx) = mpsc::sync_channel(4); - let input_ready = Arc::new(AtomicBool::new(false)); - let microphone_ready = Arc::new(AtomicBool::new(false)); - let worker_input_ready = input_ready.clone(); - let worker_microphone_ready = microphone_ready.clone(); - let transport_origin = Instant::now(); - let join = thread::Builder::new() - .name("opennow-webrtc".to_owned()) - .spawn(move || { - run_transport( - rtc, - socket, - command_rx, - TransportOutputs { - events, - media_consumer, - }, - TransportRuntime { - channels, - input_ready_state: worker_input_ready, - microphone_rx, - microphone_ready_state: worker_microphone_ready, - origin: transport_origin, - }, - ); - }) - .map_err(TransportError::Bind)?; - - Ok(NegotiatedTransport { - answer_sdp, - local_candidate: IceCandidate { - candidate: candidate_text, - sdp_mid: Some("0".to_owned()), - sdp_m_line_index: Some(0), - username_fragment: None, - }, - session: TransportSession { - commands: command_tx, - join: Some(join), - media_endpoint: normalized_offer.media_endpoint, - input_ready, - microphone: microphone_tx, - microphone_ready, - }, - }) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NormalizedOffer { - pub sdp: String, - pub replacements: usize, - pub media_endpoint: Option, -} - -pub fn normalize_offer_endpoints( - offer_sdp: &str, - session: &Session, -) -> Result { - let media_endpoint = resolve_media_endpoint(session.media_connection_info.as_ref())?; - let Some(endpoint) = media_endpoint else { - return Ok(NormalizedOffer { - sdp: offer_sdp.to_owned(), - replacements: 0, - media_endpoint: None, - }); - }; - - let mut sdp = String::with_capacity(offer_sdp.len()); - let mut replacements = 0; - for chunk in offer_sdp.split_inclusive('\n') { - let (line, ending) = chunk - .strip_suffix("\r\n") - .map(|line| (line, "\r\n")) - .or_else(|| chunk.strip_suffix('\n').map(|line| (line, "\n"))) - .unwrap_or((chunk, "")); - let rewritten = rewrite_candidate_endpoint(line, endpoint); - replacements += usize::from(rewritten != line); - sdp.push_str(&rewritten); - sdp.push_str(ending); - } - - Ok(NormalizedOffer { - sdp, - replacements, - media_endpoint: Some(endpoint), - }) -} - -pub fn resolve_server_endpoint(endpoint: &str) -> Result { - resolve_host(endpoint, 9).map_err(|source| { - if endpoint.trim().is_empty() { - TransportError::InvalidServerEndpoint("endpoint is empty".to_owned()) - } else { - TransportError::ResolveServerEndpoint { - endpoint: endpoint.to_owned(), - source, - } - } - }) -} - -fn resolve_media_endpoint( - endpoint: Option<&MediaConnectionInfo>, -) -> Result, TransportError> { - let Some(endpoint) = endpoint.filter(|endpoint| matches!(endpoint.usage, Some(2 | 17))) else { - return Ok(None); - }; - let port = u16::try_from(endpoint.port) - .ok() - .filter(|port| *port != 0) - .ok_or_else(|| { - TransportError::InvalidMediaEndpoint(format!( - "port {} is outside 1..=65535", - endpoint.port - )) - })?; - if endpoint.ip.trim().is_empty() { - return Err(TransportError::InvalidMediaEndpoint( - "hostname is empty".to_owned(), - )); - } - resolve_host(&endpoint.ip, port) - .map(|ip| Some(SocketAddr::new(ip, port))) - .map_err(|source| TransportError::ResolveMediaEndpoint { - endpoint: format!("{}:{port}", endpoint.ip), - source, - }) -} - -fn resolve_host(host: &str, port: u16) -> std::io::Result { - let host = host.trim(); - if host.is_empty() { - return Err(std::io::Error::new( - ErrorKind::InvalidInput, - "endpoint is empty", - )); - } - if let Ok(ip) = host.parse() { - return Ok(ip); - } - if let Some(ip) = dashed_ipv4_prefix(host) { - return Ok(IpAddr::V4(ip)); - } - (host, port) - .to_socket_addrs()? - .next() - .map(|address| address.ip()) - .ok_or_else(|| std::io::Error::new(ErrorKind::AddrNotAvailable, "no addresses resolved")) -} - -fn dashed_ipv4_prefix(host: &str) -> Option { - let first_label = host.split('.').next()?; - let octets = first_label - .split('-') - .map(str::parse::) - .collect::, _>>() - .ok()?; - let octets: [u8; 4] = octets.try_into().ok()?; - Some(octets.into()) -} - -fn configured_ice_schemes(servers: &[IceServer]) -> Option { - let mut schemes = servers - .iter() - .flat_map(|server| &server.urls) - .map(|url| { - url.split_once(':') - .map_or("unknown", |(scheme, _)| scheme) - .to_ascii_lowercase() - }) - .collect::>(); - if schemes.is_empty() { - return None; - } - schemes.sort_unstable(); - schemes.dedup(); - Some(schemes.join(", ")) -} - -fn normalize_remote_candidate(candidate: &str, endpoint: Option) -> String { - endpoint.map_or_else( - || candidate.to_owned(), - |endpoint| rewrite_candidate_endpoint(candidate, endpoint), - ) -} - -fn rewrite_candidate_endpoint(candidate: &str, endpoint: SocketAddr) -> String { - let candidate_body = candidate.strip_prefix("a=").unwrap_or(candidate); - if !candidate_body.starts_with("candidate:") { - return candidate.to_owned(); - } - let Some(address_range) = token_range(candidate, 4) else { - return candidate.to_owned(); - }; - let Some(port_range) = token_range(candidate, 5) else { - return candidate.to_owned(); - }; - let address = endpoint.ip().to_string(); - let port = endpoint.port().to_string(); - if candidate[address_range.clone()] == address && candidate[port_range.clone()] == port { - return candidate.to_owned(); - } - - let mut rewritten = candidate.to_owned(); - rewritten.replace_range(port_range, &port); - rewritten.replace_range(address_range, &address); - rewritten -} - -fn token_range(value: &str, target: usize) -> Option> { - let mut token = 0; - let mut start = None; - for (index, character) in value.char_indices() { - if character.is_ascii_whitespace() { - if let Some(start) = start.take() { - if token == target { - return Some(start..index); - } - token += 1; - } - } else if start.is_none() { - start = Some(index); - } - } - start - .filter(|_| token == target) - .map(|start| start..value.len()) -} - -fn bind_routed_socket(server_ip: IpAddr) -> std::io::Result { - let unspecified = match server_ip { - IpAddr::V4(_) => "0.0.0.0:0", - IpAddr::V6(_) => "[::]:0", - }; - let route_probe = UdpSocket::bind(unspecified)?; - route_probe.connect(SocketAddr::new(server_ip, 9))?; - let local_ip = route_probe.local_addr()?.ip(); - UdpSocket::bind(SocketAddr::new(local_ip, 0)) -} - -struct TransportOutputs { - events: Sender, - media_consumer: MediaConsumer, -} - -struct TransportRuntime { - channels: InputChannels, - input_ready_state: Arc, - microphone_rx: Receiver, - microphone_ready_state: Arc, - origin: Instant, -} - -fn run_transport( - mut rtc: Rtc, - socket: UdpSocket, - commands: Receiver, - outputs: TransportOutputs, - runtime: TransportRuntime, -) { - struct ResetInputReady(Arc); - - impl Drop for ResetInputReady { - fn drop(&mut self) { - self.0.store(false, Ordering::Release); - } - } - - let TransportRuntime { - channels, - input_ready_state, - microphone_rx, - microphone_ready_state, - origin: transport_origin, - } = runtime; - let _reset_input_ready = ResetInputReady(input_ready_state.clone()); - let _reset_microphone_ready = ResetInputReady(microphone_ready_state.clone()); - let mut receive_buffer = vec![0_u8; 65_536]; - let mut input_state = InputChannelState::default(); - let mut next_heartbeat = next_input_heartbeat(Instant::now()); - let mut input_handshake_started_at = None; - let mut input_timeout_reported = false; - let mut microphone_writer: Option<(Mid, Pt)> = None; - - loop { - loop { - match commands.try_recv() { - Ok(TransportCommand::AddRemoteCandidate(candidate)) => { - rtc.add_remote_candidate(candidate); - } - Ok(TransportCommand::SendInput { - bytes, - partially_reliable, - }) => { - if input_state.is_ready() - && !channels.send(&mut rtc, &bytes, partially_reliable) - { - input_ready_state.store(false, Ordering::Release); - let _ = outputs.events.send(TransportEvent::InputUnavailable( - "input packet could not be queued".to_owned(), - )); - } - } - Ok(TransportCommand::RequestKeyframe { mid }) => { - let mid = Mid::from(mid.as_str()); - let Some(mut writer) = rtc.writer(mid) else { - let _ = outputs.events.send(TransportEvent::Log(format!( - "Unable to request keyframe for unknown media id {mid}" - ))); - continue; - }; - let kind = if writer.is_request_keyframe_possible(KeyframeRequestKind::Pli) { - Some(KeyframeRequestKind::Pli) - } else if writer.is_request_keyframe_possible(KeyframeRequestKind::Fir) { - Some(KeyframeRequestKind::Fir) - } else { - None - }; - if let Some(kind) = kind - && let Err(error) = writer.request_keyframe(None, kind) - { - let _ = outputs.events.send(TransportEvent::Log(format!( - "Failed to request keyframe for {mid}: {error}" - ))); - } - } - Ok(TransportCommand::Stop) | Err(TryRecvError::Disconnected) => { - rtc.disconnect(); - let _ = outputs - .events - .send(TransportEvent::Disconnected("stopped".to_owned())); - return; - } - Err(TryRecvError::Empty) => break, - } - } - - if input_state.is_ready() && Instant::now() >= next_heartbeat { - if !channels.send_heartbeat(&mut rtc) { - input_ready_state.store(false, Ordering::Release); - let _ = outputs.events.send(TransportEvent::InputUnavailable( - "input heartbeat could not be queued".to_owned(), - )); - } - next_heartbeat = next_input_heartbeat(Instant::now()); - } - - while let Ok(frame) = microphone_rx.try_recv() { - let Some((mid, pt)) = microphone_writer else { - break; - }; - let Some(writer) = rtc.writer(mid) else { - microphone_writer = None; - microphone_ready_state.store(false, Ordering::Release); - let _ = outputs.events.send(TransportEvent::MicrophoneUnavailable( - "Negotiated microphone media disappeared".to_owned(), - )); - break; - }; - if let Err(error) = writer - .audio_level(frame.audio_level_db, frame.voice_activity) - .write( - pt, - transport_origin + Duration::from_micros(frame.captured_at_us), - MediaTime::from_micros(frame.captured_at_us), - frame.payload, - ) - { - microphone_writer = None; - microphone_ready_state.store(false, Ordering::Release); - let _ = outputs - .events - .send(TransportEvent::MicrophoneUnavailable(format!( - "Microphone media could not be queued: {error}" - ))); - break; - } - } - if !input_timeout_reported - && !input_state.is_ready() - && input_handshake_started_at.is_some_and(|started| { - Instant::now().duration_since(started) >= Duration::from_secs(5) - }) - { - input_timeout_reported = true; - let _ = outputs.events.send(TransportEvent::InputUnavailable( - "input handshake timed out".to_owned(), - )); - } - - let timeout = loop { - match rtc.poll_output() { - Ok(Output::Timeout(timeout)) => break timeout, - Ok(Output::Transmit(transmit)) => { - if let Err(error) = socket.send_to(&transmit.contents, transmit.destination) { - let _ = outputs.events.send(TransportEvent::Disconnected(format!( - "UDP send failed: {error}" - ))); - return; - } - } - Ok(Output::Event(event)) => match event { - Event::IceConnectionStateChange(IceConnectionState::Connected) => { - input_handshake_started_at.get_or_insert_with(Instant::now); - let _ = outputs.events.send(TransportEvent::Connected); - } - Event::IceConnectionStateChange(IceConnectionState::Disconnected) => { - let _ = outputs - .events - .send(TransportEvent::Disconnected("ICE disconnected".to_owned())); - return; - } - Event::ChannelOpen(id, _) => { - if let Some(version) = input_state.channel_opened(channels, id) { - input_ready_state.store(true, Ordering::Release); - let _ = outputs.events.send(TransportEvent::InputReady(version)); - } - } - Event::ChannelData(data) => { - if let Some(version) = - input_state.channel_data(channels, data.id, &data.data) - { - input_ready_state.store(true, Ordering::Release); - let _ = outputs.events.send(TransportEvent::InputReady(version)); - } - } - Event::ChannelClose(id) => { - if input_state.channel_closed(channels, id) { - input_ready_state.store(false, Ordering::Release); - let _ = outputs.events.send(TransportEvent::InputUnavailable( - "input data channel closed".to_owned(), - )); - } - } - Event::MediaAdded(media) - if media.kind == MediaKind::Audio && media.direction.is_sending() => - { - let mid = media.mid; - let payload = rtc.writer(mid).and_then(|writer| { - writer - .payload_params() - .find(|params| params.spec().codec == Codec::Opus) - .map(|params| params.pt()) - }); - if let Some(payload) = payload { - microphone_writer = Some((mid, payload)); - microphone_ready_state.store(true, Ordering::Release); - let _ = outputs.events.send(TransportEvent::MicrophoneReady); - } - } - Event::MediaChanged(media) if !media.direction.is_sending() => { - if microphone_writer.is_some_and(|(mid, _)| mid == media.mid) { - microphone_writer = None; - microphone_ready_state.store(false, Ordering::Release); - let _ = outputs.events.send(TransportEvent::MicrophoneUnavailable( - "Remote session disabled microphone media".to_owned(), - )); - } - } - Event::MediaData(data) => { - let keyframe = data.is_keyframe(); - let received_at_us = data - .network_time - .saturating_duration_since(transport_origin) - .as_micros() - .try_into() - .unwrap_or(u64::MAX); - let frame = EncodedMediaFrame { - mid: data.mid.to_string(), - codec: format!("{:?}", data.params.spec().codec), - payload: data.data, - rtp_timestamp: data.time.numer(), - clock_rate_hz: data.time.denom(), - channels: data.params.spec().channels, - received_at_us, - keyframe, - contiguous: data.contiguous, - }; - if let Err(error) = deliver_media_frame(&outputs.media_consumer, frame) { - let _ = outputs - .events - .send(TransportEvent::Disconnected(error.to_string())); - return; - } - } - _ => {} - }, - Err(error) => { - let _ = outputs - .events - .send(TransportEvent::Disconnected(error.to_string())); - return; - } - } - }; - - let wait = timeout - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(10)); - if wait.is_zero() { - if let Err(error) = rtc.handle_input(Input::Timeout(Instant::now())) { - let _ = outputs.events.send(TransportEvent::Disconnected(format!( - "RTC timer failed: {error}" - ))); - return; - } - continue; - } - - if let Err(error) = socket.set_read_timeout(Some(wait)) { - let _ = outputs.events.send(TransportEvent::Disconnected(format!( - "UDP timeout configuration failed: {error}" - ))); - return; - } - let input = match socket.recv_from(&mut receive_buffer) { - Ok((length, source)) => { - let destination = match socket.local_addr() { - Ok(value) => value, - Err(error) => { - let _ = outputs - .events - .send(TransportEvent::Disconnected(error.to_string())); - return; - } - }; - let contents = match receive_buffer[..length].try_into() { - Ok(value) => value, - Err(error) => { - let _ = outputs.events.send(TransportEvent::Log(format!( - "Dropping oversized UDP packet: {error}" - ))); - continue; - } - }; - Input::Receive( - Instant::now(), - Receive { - proto: Protocol::Udp, - source, - destination, - contents, - }, - ) - } - Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { - Input::Timeout(Instant::now()) - } - Err(error) => { - let _ = outputs - .events - .send(TransportEvent::Disconnected(error.to_string())); - return; - } - }; - if let Err(error) = rtc.handle_input(input) { - let _ = outputs - .events - .send(TransportEvent::Disconnected(error.to_string())); - return; - } - } -} - #[cfg(test)] mod tests { use super::*; - use opennow_streamer_protocol::Session; - use serde_json::json; - use str0m::media::{Direction, MediaKind}; - - fn synthetic_session(media_connection_info: serde_json::Value) -> Session { - serde_json::from_value(json!({ - "sessionId": "synthetic-session", - "serverIp": "127-0-0-1.session.synthetic.invalid", - "iceServers": [], - "mediaConnectionInfo": media_connection_info, - })) - .expect("synthetic session") - } - - #[test] - fn normalizes_synthetic_gfn_media_endpoint_candidates() { - let session = synthetic_session(json!({ - "ip": "198-51-100-42.media.synthetic.invalid", - "port": 18_784, - "usage": 17, - })); - let offer = [ - "v=0", - "c=IN IP4 0.0.0.0", - "a=candidate:udp 1 udp 2122260223 0.0.0.0 47998 typ host", - "a=candidate:tcp 1 tcp 1518214911 203.0.113.9 9 typ host tcptype active", - ] - .join("\r\n"); - - let normalized = normalize_offer_endpoints(&offer, &session).expect("normalized offer"); - - assert_eq!(normalized.replacements, 2); - assert_eq!( - normalized.media_endpoint, - Some("198.51.100.42:18784".parse().expect("socket address")) - ); - assert!(normalized.sdp.contains("c=IN IP4 0.0.0.0")); - assert!( - normalized - .sdp - .contains("a=candidate:udp 1 udp 2122260223 198.51.100.42 18784 typ host") - ); - assert!(normalized.sdp.contains( - "a=candidate:tcp 1 tcp 1518214911 198.51.100.42 18784 typ host tcptype active" - )); - assert!(normalized.sdp.contains("\r\n")); - } - - #[test] - fn rewrites_trickled_candidate_to_the_normalized_media_endpoint() { - let endpoint = Some("198.51.100.42:18784".parse().expect("socket address")); - let candidate = "candidate:remote 1 udp 2122260223 203.0.113.9 47998 typ host"; - - assert_eq!( - normalize_remote_candidate(candidate, endpoint), - "candidate:remote 1 udp 2122260223 198.51.100.42 18784 typ host" - ); - assert_eq!( - normalize_remote_candidate("candidate:malformed", endpoint), - "candidate:malformed" - ); - } - - #[test] - fn skips_non_webrtc_media_endpoint_usage() { - let session = synthetic_session(json!({ - "ip": "198.51.100.42", - "port": 18_784, - "usage": 14, - })); - let offer = "v=0\na=candidate:udp 1 udp 1 203.0.113.9 47998 typ host\n"; - - let normalized = normalize_offer_endpoints(offer, &session).expect("normalized offer"); - - assert_eq!(normalized.sdp, offer); - assert_eq!(normalized.replacements, 0); - assert_eq!(normalized.media_endpoint, None); - } - - #[test] - fn accepts_ip_encoded_and_dns_hostnames_as_server_endpoints() { - assert_eq!( - resolve_server_endpoint("127-0-0-1.session.synthetic.invalid") - .expect("encoded hostname"), - "127.0.0.1".parse::().expect("IP address") - ); - assert!( - resolve_server_endpoint("localhost") - .expect("DNS hostname") - .is_loopback() - ); - } - - #[test] - fn rejects_invalid_media_endpoint_port() { - let session = synthetic_session(json!({ - "ip": "198.51.100.42", - "port": 70_000, - "usage": 2, - })); - - let error = normalize_offer_endpoints("v=0\n", &session).expect_err("invalid endpoint"); - assert_eq!(error.code(), "invalid-media-endpoint"); - assert!(error.to_string().contains("1..=65535")); - } - - #[test] - fn summarizes_configured_ice_schemes_without_credentials() { - let servers: Vec = serde_json::from_value(json!([ - { - "urls": ["stun:stun.synthetic.invalid:3478"], - }, - { - "urls": ["turns:turn.synthetic.invalid:5349"], - "username": "synthetic-user", - "credential": "synthetic-secret" - } - ])) - .expect("ICE servers"); - - let schemes = configured_ice_schemes(&servers).expect("configured schemes"); - - assert_eq!(schemes, "stun, turns"); - assert!(!schemes.contains("synthetic-secret")); - } - - #[test] - fn negotiates_a_synthetic_offer_with_a_hostname_server_endpoint() { - 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("offer"); - let offer_sdp = offer.to_sdp_string(); - let session = synthetic_session(serde_json::Value::Null); - let (events, _receiver) = mpsc::channel(); - let (media_consumer, _media_receiver) = mpsc::sync_channel(4); - - let negotiated = negotiate( - &offer_sdp, - &session, - NegotiatedVideoCodec::H264, - 300, - events, - media_consumer, - ) - .expect("negotiated answer"); - - assert!(negotiated.answer_sdp.contains("m=video")); - assert!(!negotiated.answer_sdp.contains("m=video 0")); - assert!(negotiated.answer_sdp.contains("H264/90000")); - assert!(!negotiated.answer_sdp.contains("AV1/90000")); - assert!(!negotiated.answer_sdp.contains("H265/90000")); - negotiated.session.stop(); - } - - #[test] - fn answer_advertises_only_the_decoder_selected_hevc_codec() { - let answer = synthetic_video_answer(NegotiatedVideoCodec::H265); - - assert!(answer.contains("H265/90000")); - assert!(!answer.contains("H264/90000")); - assert!(!answer.contains("AV1/90000")); - } - - #[test] - fn answer_advertises_only_the_decoder_selected_av1_codec() { - let answer = synthetic_video_answer(NegotiatedVideoCodec::Av1); - - assert!(answer.contains("AV1/90000")); - assert!(!answer.contains("H264/90000")); - assert!(!answer.contains("H265/90000")); - } - - fn synthetic_video_answer(codec: NegotiatedVideoCodec) -> String { - install_crypto(); - let mut offerer = RtcConfig::new().build(Instant::now()); - offerer.add_local_candidate( - Candidate::host("127.0.0.1:49154".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("offer"); - let session = synthetic_session(serde_json::Value::Null); - let (events, _receiver) = mpsc::channel(); - let (media_consumer, _media_receiver) = mpsc::sync_channel(4); - - let negotiated = negotiate( - &offer.to_sdp_string(), - &session, - codec, - 300, - events, - media_consumer, - ) - .expect("negotiated codec-specific answer"); - let answer = negotiated.answer_sdp.clone(); - negotiated.session.stop(); - answer - } - - #[test] - fn negotiates_opus_microphone_media_when_the_peer_receives_audio() { - install_crypto(); - let mut offerer = RtcConfig::new().build(Instant::now()); - offerer.add_local_candidate( - Candidate::host("127.0.0.1:49153".parse().expect("candidate address"), "udp") - .expect("local candidate"), - ); - let mut change = offerer.sdp_api(); - change.add_media(MediaKind::Audio, Direction::RecvOnly, None, None, None); - let (offer, _pending) = change.apply().expect("offer"); - let session = synthetic_session(serde_json::Value::Null); - let (events, _receiver) = mpsc::channel(); - let (media_consumer, _media_receiver) = mpsc::sync_channel(4); - - let negotiated = negotiate( - &offer.to_sdp_string(), - &session, - NegotiatedVideoCodec::H264, - 300, - events, - media_consumer, - ) - .expect("negotiated microphone answer"); - let audio_section = negotiated - .answer_sdp - .split("m=audio") - .nth(1) - .expect("audio section") - .split("m=") - .next() - .expect("bounded audio section"); - assert!(audio_section.to_ascii_lowercase().contains("opus/48000/2")); - assert!(audio_section.contains("a=sendonly")); - negotiated.session.stop(); - } - - #[test] - fn delivers_encoded_payload_to_typed_consumer_without_copying_arc() { - let (consumer, receiver) = mpsc::sync_channel(1); - let payload: Arc<[u8]> = Arc::from([1_u8, 2, 3, 4]); - let frame = EncodedMediaFrame { + fn frame() -> EncodedMediaFrame { + EncodedMediaFrame { mid: "video-0".to_owned(), codec: "H264".to_owned(), - payload: payload.clone(), + payload: Arc::from([1_u8, 2, 3, 4]), rtp_timestamp: 180_000, clock_rate_hz: 90_000, channels: None, received_at_us: 2_500, keyframe: true, contiguous: true, - }; - - deliver_media_frame(&consumer, frame).expect("frame delivery"); + } + } + #[test] + fn media_delivery_preserves_shared_payload() { + let (consumer, receiver) = std::sync::mpsc::sync_channel(1); + let expected = frame(); + let payload = expected.payload.clone(); + deliver_media_frame(&consumer, expected).expect("frame delivery"); let delivered = receiver.recv().expect("delivered frame"); assert!(Arc::ptr_eq(&delivered.payload, &payload)); - assert_eq!(delivered.rtp_timestamp, 180_000); - assert_eq!(delivered.clock_rate_hz, 90_000); - assert_eq!(delivered.channels, None); - assert_eq!(delivered.received_at_us, 2_500); } #[test] - fn reports_media_consumer_backpressure_instead_of_growing_an_unbounded_queue() { - let (consumer, _receiver) = mpsc::sync_channel(1); - let frame = || EncodedMediaFrame { - mid: "video-0".to_owned(), - codec: "H264".to_owned(), - payload: Arc::from([1_u8, 2, 3, 4]), - rtp_timestamp: 180_000, - clock_rate_hz: 90_000, - channels: None, - received_at_us: 2_500, - keyframe: true, - contiguous: true, - }; + fn media_delivery_reports_bounded_queue_backpressure() { + let (consumer, _receiver) = std::sync::mpsc::sync_channel(1); deliver_media_frame(&consumer, frame()).expect("first frame delivery"); - - let error = deliver_media_frame(&consumer, frame()).expect_err("bounded queue is full"); - - assert_eq!(error.code(), "media-consumer-backpressured"); + assert!(matches!( + deliver_media_frame(&consumer, frame()), + Err(TransportError::MediaConsumerBackpressured) + )); } } diff --git a/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs b/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs index a4caf0f20..78c5a4618 100644 --- a/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs +++ b/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs @@ -237,7 +237,6 @@ impl NvstVideoCodec { } } -/// Why an NVST handoff cannot be selected. Callers should retain their WebRTC fallback. #[derive(Debug, Error)] pub enum NvstConfigError { #[error("nvstVideo must be an object")] @@ -1126,7 +1125,6 @@ fn required_ice_credential( } /// Parses `context.nvstVideo` without tying the rest of the transport to JSON field names. -/// `None` means the legacy handoff was not supplied, which is a normal WebRTC fallback case. pub fn parse_nvst_video_handoff( context: &Value, ) -> Result, NvstConfigError> { @@ -1150,30 +1148,6 @@ pub fn parse_nvst_video_handoff( Ok(Some(config)) } -/// The transport selector always prefers a valid NVST video handoff, while making every -/// incomplete or unsupported handoff a typed WebRTC fallback instead of an optimistic start. -#[derive(Debug)] -pub enum PreferredVideoTransport { - Nvst(Box), - WebRtcFallback(NvstFallbackReason), -} - -#[derive(Debug)] -pub enum NvstFallbackReason { - NoNvstHandoff, - InvalidNvstHandoff(NvstConfigError), -} - -pub fn select_preferred_video_transport(context: &Value) -> PreferredVideoTransport { - match parse_nvst_video_handoff(context) { - Ok(Some(config)) => PreferredVideoTransport::Nvst(Box::new(config)), - Ok(None) => PreferredVideoTransport::WebRtcFallback(NvstFallbackReason::NoNvstHandoff), - Err(error) => { - PreferredVideoTransport::WebRtcFallback(NvstFallbackReason::InvalidNvstHandoff(error)) - } - } -} - fn required_string<'a>( object: &'a serde_json::Map, field: &'static str, @@ -6175,12 +6149,9 @@ mod tests { } #[test] - fn selector_prefers_valid_legacy_nvst_and_falls_back_for_h265() { + fn parses_valid_legacy_nvst_and_rejects_invalid_handoffs() { let context = json!({ "nvstVideo": legacy_handoff() }); - assert!(matches!( - select_preferred_video_transport(&context), - PreferredVideoTransport::Nvst(_) - )); + assert!(parse_nvst_video_handoff(&context).unwrap().is_some()); let configured = parse_nvst_video_handoff(&json!({ "nvstVideo": legacy_handoff(), "settings": { "fps": 60 }, @@ -6197,10 +6168,8 @@ mod tests { .expect("NVST handoff"); assert_eq!(capped.frame_time_us, 4_166); assert!(matches!( - select_preferred_video_transport(&json!({ "nvstTransport": { "tracks": [] } })), - PreferredVideoTransport::WebRtcFallback(NvstFallbackReason::InvalidNvstHandoff( - NvstConfigError::MissingNvstVideoHandoff - )) + parse_nvst_video_handoff(&json!({ "nvstTransport": { "tracks": [] } })), + Err(NvstConfigError::MissingNvstVideoHandoff) )); let mut missing_gcm_salt = legacy_handoff(); @@ -6217,10 +6186,8 @@ mod tests { invalid["codec"] = json!("VP9"); let context = json!({ "nvstVideo": invalid }); assert!(matches!( - select_preferred_video_transport(&context), - PreferredVideoTransport::WebRtcFallback(NvstFallbackReason::InvalidNvstHandoff( - NvstConfigError::UnsupportedCodec(_) - )) + parse_nvst_video_handoff(&context), + Err(NvstConfigError::UnsupportedCodec(_)) )); let mut missing_cm_salt = legacy_handoff(); diff --git a/opennow-qt/CMakeLists.txt b/opennow-qt/CMakeLists.txt index d48ab4417..39c96e2bb 100644 --- a/opennow-qt/CMakeLists.txt +++ b/opennow-qt/CMakeLists.txt @@ -15,6 +15,10 @@ set(CMAKE_AUTOMOC ON) find_package(Qt6 6.8 REQUIRED COMPONENTS Quick QuickControls2 Network Multimedia Test) find_package(SDL3 REQUIRED CONFIG) +find_package(Threads REQUIRED) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + find_package(Vulkan REQUIRED) +endif() if(TARGET SDL3::SDL3-shared) set(OPENNOW_SDL3_RUNTIME_TARGET SDL3::SDL3-shared) elseif(TARGET SDL3::SDL3-static) @@ -54,10 +58,12 @@ qt_add_executable(opennow-qt src/InputModeTracker.h src/Localization.cpp src/Localization.h + src/NativeStreamRuntime.cpp + src/NativeStreamRuntime.h src/SingleInstance.cpp src/SingleInstance.h - src/StreamSurfaceController.cpp - src/StreamSurfaceController.h + src/StreamVideoItem.cpp + src/StreamVideoItem.h src/ThumbnailGenerator.cpp src/ThumbnailGenerator.h ) @@ -244,10 +250,14 @@ target_include_directories(opennow-qt PRIVATE src) target_link_libraries(opennow-qt PRIVATE Qt6::Quick Qt6::QuickControls2 + Qt6::GuiPrivate Qt6::Network Qt6::Multimedia SDL3::SDL3 ) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_link_libraries(opennow-qt PRIVATE Vulkan::Vulkan) +endif() if(WIN32) add_custom_command(TARGET opennow-qt POST_BUILD @@ -282,6 +292,7 @@ endif() target_compile_definitions(opennow-qt PRIVATE QT_NO_CAST_FROM_ASCII QT_NO_CAST_TO_ASCII + OPENNOW_EMBEDDED_STREAMER OPENNOW_VERSION="${OPENNOW_BUILD_VERSION}" ) @@ -347,10 +358,9 @@ if(OPENNOW_RUST_TARGET) set(OPENNOW_STREAMER_ARTIFACT_ROOT "${OPENNOW_STREAMER_TARGET_DIR}/${OPENNOW_RUST_TARGET}") endif() -# The streamer is an optimized subprocess that decodes and presents video in -# real time; a debug-profile build of it is too slow to stream with and has no -# debugging value for Qt or QML work. Build it as release regardless of the Qt -# configuration so what runs locally is what ships. +# The embedded streamer decodes and publishes GPU frames in process. A debug +# Rust build is too slow for real-time playback, so link its release library +# into every Qt configuration. set(OPENNOW_STREAMER_PROFILE "release") set(OPENNOW_STREAMER_CARGO_FEATURE_ARGS) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") @@ -362,25 +372,79 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") list(APPEND OPENNOW_STREAMER_CARGO_FEATURE_ARGS --features linux-ffmpeg-bundled) endif() -option(OPENNOW_BUILD_STREAMER "Build and bundle the native media streamer" ON) -if(OPENNOW_BUILD_STREAMER) - add_custom_target(opennow-streamer ALL - COMMAND "${CMAKE_COMMAND}" -E env "CMAKE=${CMAKE_COMMAND}" - "${CARGO_EXECUTABLE}" build - --manifest-path "${CMAKE_CURRENT_SOURCE_DIR}/../native/opennow-streamer/Cargo.toml" - --target-dir "${OPENNOW_STREAMER_TARGET_DIR}" - ${OPENNOW_RUST_TARGET_ARGS} - ${OPENNOW_STREAMER_CARGO_FEATURE_ARGS} - --release - COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${OPENNOW_STREAMER_ARTIFACT_ROOT}/${OPENNOW_STREAMER_PROFILE}/opennow-streamer${OPENNOW_CORE_SUFFIX}" - "$/opennow-streamer${OPENNOW_CORE_SUFFIX}" - WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/../native/opennow-streamer" - COMMENT "Building and bundling the OpenNOW native media streamer" - COMMAND_EXPAND_LISTS - VERBATIM - ) - add_dependencies(opennow-qt opennow-streamer) +if(MSVC) + set(OPENNOW_STREAMER_FFI_RUNTIME_NAME "opennow_streamer_ffi.dll") + set(OPENNOW_STREAMER_FFI_LINK_NAME "opennow_streamer_ffi.dll.lib") +elseif(WIN32) + set(OPENNOW_STREAMER_FFI_RUNTIME_NAME "opennow_streamer_ffi.dll") + set(OPENNOW_STREAMER_FFI_LINK_NAME "libopennow_streamer_ffi.dll.a") +elseif(APPLE) + set(OPENNOW_STREAMER_FFI_RUNTIME_NAME "libopennow_streamer_ffi.dylib") + set(OPENNOW_STREAMER_FFI_LINK_NAME "${OPENNOW_STREAMER_FFI_RUNTIME_NAME}") +else() + set(OPENNOW_STREAMER_FFI_RUNTIME_NAME "libopennow_streamer_ffi.so") + set(OPENNOW_STREAMER_FFI_LINK_NAME "${OPENNOW_STREAMER_FFI_RUNTIME_NAME}") +endif() +set(OPENNOW_STREAMER_FFI_RUNTIME + "${OPENNOW_STREAMER_ARTIFACT_ROOT}/${OPENNOW_STREAMER_PROFILE}/${OPENNOW_STREAMER_FFI_RUNTIME_NAME}") +set(OPENNOW_STREAMER_FFI_LINK_LIBRARY + "${OPENNOW_STREAMER_ARTIFACT_ROOT}/${OPENNOW_STREAMER_PROFILE}/${OPENNOW_STREAMER_FFI_LINK_NAME}") +set(OPENNOW_STREAMER_FFI_ARTIFACTS + "${OPENNOW_STREAMER_FFI_RUNTIME}" "${OPENNOW_STREAMER_FFI_LINK_LIBRARY}") +list(REMOVE_DUPLICATES OPENNOW_STREAMER_FFI_ARTIFACTS) +file(GLOB_RECURSE OPENNOW_STREAMER_RUST_SOURCES CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/../native/opennow-streamer/crates/*.rs" + "${CMAKE_CURRENT_SOURCE_DIR}/../native/opennow-streamer/crates/*/Cargo.toml") +add_custom_command( + OUTPUT ${OPENNOW_STREAMER_FFI_ARTIFACTS} + COMMAND "${CMAKE_COMMAND}" -E env --unset=MAKEFLAGS --unset=MFLAGS + "CMAKE=${CMAKE_COMMAND}" + "${CARGO_EXECUTABLE}" build + --manifest-path "${CMAKE_CURRENT_SOURCE_DIR}/../native/opennow-streamer/Cargo.toml" + --target-dir "${OPENNOW_STREAMER_TARGET_DIR}" + --package opennow-streamer-ffi + --lib + ${OPENNOW_RUST_TARGET_ARGS} + ${OPENNOW_STREAMER_CARGO_FEATURE_ARGS} + --release + DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/../native/opennow-streamer/Cargo.lock" + "${CMAKE_CURRENT_SOURCE_DIR}/../native/opennow-streamer/Cargo.toml" + "${CMAKE_CURRENT_SOURCE_DIR}/../native/opennow-streamer/crates/opennow-streamer-ffi/include/opennow_streamer_ffi.h" + ${OPENNOW_STREAMER_RUST_SOURCES} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/../native/opennow-streamer" + COMMENT "Building the embedded OpenNOW streamer library" + COMMAND_EXPAND_LISTS + VERBATIM +) +add_custom_target(opennow-streamer-ffi-build + DEPENDS ${OPENNOW_STREAMER_FFI_ARTIFACTS}) +add_library(opennow-streamer-ffi SHARED IMPORTED GLOBAL) +if(WIN32) + set_target_properties(opennow-streamer-ffi PROPERTIES + IMPORTED_IMPLIB "${OPENNOW_STREAMER_FFI_LINK_LIBRARY}" + IMPORTED_LOCATION "${OPENNOW_STREAMER_FFI_RUNTIME}") +else() + set_target_properties(opennow-streamer-ffi PROPERTIES + IMPORTED_LOCATION "${OPENNOW_STREAMER_FFI_LINK_LIBRARY}") +endif() +set_target_properties(opennow-streamer-ffi PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES + "${CMAKE_CURRENT_SOURCE_DIR}/../native/opennow-streamer/crates/opennow-streamer-ffi/include") +add_dependencies(opennow-streamer-ffi opennow-streamer-ffi-build) +target_link_libraries(opennow-qt PRIVATE opennow-streamer-ffi) +add_dependencies(opennow-qt opennow-streamer-ffi-build) +add_custom_command(TARGET opennow-qt POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${OPENNOW_STREAMER_FFI_RUNTIME}" + "$/${OPENNOW_STREAMER_FFI_RUNTIME_NAME}" + VERBATIM) +if(APPLE) + set_property(TARGET opennow-qt APPEND PROPERTY BUILD_RPATH "@loader_path") + set_property(TARGET opennow-qt APPEND PROPERTY INSTALL_RPATH "@loader_path") +elseif(UNIX) + set_property(TARGET opennow-qt APPEND PROPERTY BUILD_RPATH "$ORIGIN") + set_property(TARGET opennow-qt APPEND PROPERTY INSTALL_RPATH "$ORIGIN") endif() if(MSVC) @@ -431,26 +495,47 @@ if(BUILD_TESTING) add_dependencies(opennow-coreclient-tests opennow-fake-core) add_test(NAME opennow-coreclient-tests COMMAND opennow-coreclient-tests -o -,txt) - qt_add_executable(opennow-streamsurface-tests - tests/tst_streamsurfacecontroller.cpp - src/StreamSurfaceController.cpp - src/StreamSurfaceController.h - src/AppController.cpp - src/AppController.h - src/CoreClient.cpp - src/CoreClient.h + qt_add_executable(opennow-streamvideo-tests + tests/tst_streamvideoitem.cpp + src/NativeStreamRuntime.cpp + src/NativeStreamRuntime.h + src/StreamVideoItem.cpp + src/StreamVideoItem.h ) - target_include_directories(opennow-streamsurface-tests PRIVATE src) - target_link_libraries(opennow-streamsurface-tests PRIVATE - Qt6::Test Qt6::Core Qt6::Gui Qt6::Quick) - target_compile_definitions(opennow-streamsurface-tests PRIVATE - OPENNOW_VERSION="${OPENNOW_BUILD_VERSION}") - add_dependencies(opennow-streamsurface-tests opennow-fake-core) - add_test(NAME opennow-streamsurface-tests - COMMAND opennow-streamsurface-tests -o -,txt) - set_tests_properties(opennow-streamsurface-tests PROPERTIES - ENVIRONMENT "QT_QPA_PLATFORM=offscreen" - TIMEOUT 8) + target_include_directories(opennow-streamvideo-tests PRIVATE src) + target_link_libraries(opennow-streamvideo-tests PRIVATE + Qt6::Test Qt6::Core Qt6::Gui Qt6::GuiPrivate Qt6::Qml Qt6::Quick + opennow-streamer-ffi) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_link_libraries(opennow-streamvideo-tests PRIVATE Vulkan::Vulkan) + endif() + add_dependencies(opennow-streamvideo-tests opennow-streamer-ffi-build) + add_test(NAME opennow-streamvideo-tests + COMMAND opennow-streamvideo-tests -o -,txt) + set_tests_properties(opennow-streamvideo-tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + + qt_add_executable(opennow-nativestreamruntime-tests + tests/tst_nativestreamruntime.cpp + src/NativeStreamRuntime.cpp + src/NativeStreamRuntime.h + ) + target_include_directories(opennow-nativestreamruntime-tests PRIVATE src) + target_link_libraries(opennow-nativestreamruntime-tests PRIVATE + Qt6::Test Qt6::Core opennow-streamer-ffi) + add_dependencies(opennow-nativestreamruntime-tests opennow-streamer-ffi-build) + add_test(NAME opennow-nativestreamruntime-tests + COMMAND opennow-nativestreamruntime-tests -o -,txt) + set_tests_properties(opennow-nativestreamruntime-tests PROPERTIES TIMEOUT 8) + + qt_add_executable(opennow-embedded-orchestration-tests + tests/tst_embeddedorchestration.cpp + ) + target_link_libraries(opennow-embedded-orchestration-tests PRIVATE Qt6::Test Qt6::Core) + target_compile_definitions(opennow-embedded-orchestration-tests PRIVATE + OPENNOW_QT_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + add_test(NAME opennow-embedded-orchestration-tests + COMMAND opennow-embedded-orchestration-tests -o -,txt) qt_add_executable(opennow-singleinstance-tests tests/tst_singleinstance.cpp @@ -496,7 +581,9 @@ if(BUILD_TESTING) opennow-localization-tests opennow-qt-tests opennow-coreclient-tests - opennow-streamsurface-tests + opennow-streamvideo-tests + opennow-nativestreamruntime-tests + opennow-embedded-orchestration-tests opennow-singleinstance-tests opennow-thumbnail-tests opennow-controllerinput-tests @@ -515,7 +602,9 @@ if(BUILD_TESTING) opennow-localization-tests opennow-qt-tests opennow-coreclient-tests - opennow-streamsurface-tests + opennow-streamvideo-tests + opennow-nativestreamruntime-tests + opennow-embedded-orchestration-tests opennow-singleinstance-tests opennow-thumbnail-tests opennow-controllerinput-tests) @@ -630,10 +719,14 @@ endif() if(NOT APPLE) install(PROGRAMS "$/opennow-core${OPENNOW_CORE_SUFFIX}" - "$/opennow-streamer${OPENNOW_CORE_SUFFIX}" "$/opennow-acceptance-verify${OPENNOW_CORE_SUFFIX}" DESTINATION "${CMAKE_INSTALL_BINDIR}" ) + install(PROGRAMS "${OPENNOW_STREAMER_FFI_RUNTIME}" + DESTINATION "${CMAKE_INSTALL_BINDIR}") +else() + install(FILES "${OPENNOW_STREAMER_FFI_RUNTIME}" + DESTINATION "${OPENNOW_EXECUTABLE_NAME}.app/Contents/MacOS") endif() if(APPLE) diff --git a/opennow-qt/README.md b/opennow-qt/README.md index 6ba6a6933..54a60667f 100644 --- a/opennow-qt/README.md +++ b/opennow-qt/README.md @@ -49,23 +49,27 @@ The versioned Rust core owns settings, NVIDIA device login and token refresh, OS-protected accounts, PINs, catalogs, subscriptions, regions and latency tests, account connections, persistent storage, CloudMatch lifecycle/recovery/ads, NVST session orchestration, diagnostics, media listing, Discord, telemetry, -feedback and update discovery. The supervised protocol-v5 native streamer owns -the complete NVST negotiation, its media sockets, hardware/software decode, -audio, native input and presentation controls. Qt/QML owns stream status, stats, -menus, recovery, failure and fullscreen chrome. The Qt shell sends one -complete CloudMatch session context and never proxies RTSPS or ICE/SRTP state. -CPack installs the shell, core and streamer together and can generate -a Linux deb locally, while CI defines a checksum-pinned x64 AppImage build. +feedback and update discovery. The protocol-v5 native streamer is linked into +the Qt executable as an in-process Rust library. It owns NVST RTSPS negotiation, +Mjolnir video, the ICE/DTLS/SCTP control bundle, decode, audio and native input. +Qt/QML owns stream status, stats, menus, recovery, failure and fullscreen +chrome. The shell sends one complete CloudMatch session context and never +proxies RTSPS, ICE, SRTP or encoded media. Decoders publish native GPU frames +through the C FFI; `StreamVideoItem` imports and samples them on Qt's QRhi +render command stream without a child streamer process or native presenter +window. CPack installs the Qt executable, `opennow-core` and the runtime library; +there is no separate streamer application. CI produces the platform packages +from that layout. The screenshot shortcut captures the exact stream region, and F12 records the negotiated H.264/H.265/AV1 source stream plus Opus audio atomically into Matroska -before generating a media thumbnail. NVST microphone upstream is not currently -supported and fails closed. Live multi-OS streaming, window-composition and +before generating a media thumbnail. Microphone capture is not part of the +native NVST runtime. Live multi-OS streaming, GPU interop and hardware validation plus production signing/notarization remain removal gates, so Electron is still retained as the shipping fallback. -The current presenter is not a Qt scene-graph texture. Windows reparents its native -presenter HWND into the Qt top-level window, while macOS, X11 and Wayland use paired -native top-level windows. Ordinary QML cannot reliably cover those surfaces, so -opening Qt-owned stream UI temporarily hides native presentation and restores it -afterward. This is deliberately not described as a composited live-video overlay or -cross-platform zero-copy. +The current presenter is a Qt scene-graph item. Platform decoders retain native +textures, record any required conversion and synchronization into the active +QRhi command buffer, and expose only opaque frame tokens across the FFI. QML +overlays therefore compose normally above the stream. No CPU frame callback, +standalone SDL presenter, child HWND or paired top-level video window participates +in the Qt path. diff --git a/opennow-qt/packaging/Info.plist.in b/opennow-qt/packaging/Info.plist.in index dfcab299e..8544221da 100644 --- a/opennow-qt/packaging/Info.plist.in +++ b/opennow-qt/packaging/Info.plist.in @@ -37,8 +37,6 @@ public.app-category.games NSHighResolutionCapable - NSMicrophoneUsageDescription - OpenNOW uses the microphone only when you enable voice chat for a cloud gaming session. NSSupportsAutomaticGraphicsSwitching diff --git a/opennow-qt/qml/desktop/DesktopStreamScreen.qml b/opennow-qt/qml/desktop/DesktopStreamScreen.qml index 14385e719..64983adb2 100644 --- a/opennow-qt/qml/desktop/DesktopStreamScreen.qml +++ b/opennow-qt/qml/desktop/DesktopStreamScreen.qml @@ -14,6 +14,7 @@ FocusScope { readonly property var game: ShellStore.selectedGame || ({}) readonly property var session: ShellStore.activeSession || ({}) + readonly property var profile: session.negotiatedStreamProfile || session.streamProfile || ({}) readonly property var streamer: ShellStore.streamer || ({}) readonly property string status: { if (ShellStore.streamState === "error") @@ -37,13 +38,45 @@ FocusScope { return qsTr("STARTING VIDEO") } - Item { + function publishCaptureRect() { + const window = root.Window.window + if (!window) + return + const point = root.mapToItem(null, 0, 0) + ShellStore.streamCaptureRect = Qt.rect( + window.x + point.x, window.y + point.y, root.width, root.height) + } + + onXChanged: publishCaptureRect() + onYChanged: publishCaptureRect() + onWidthChanged: publishCaptureRect() + onHeightChanged: publishCaptureRect() + Component.onCompleted: publishCaptureRect() + + Connections { + target: root.Window.window + function onXChanged() { root.publishCaptureRect() } + function onYChanged() { root.publishCaptureRect() } + } + + StreamVideoItem { + id: streamVideo objectName: "streamSurfaceHost" anchors.fill: parent - visible: root.visible && root.streaming && AppController.overlay === "" + visible: root.visible && root.streaming + focus: visible + inputEnabled: visible && AppController.overlay === "" + videoSize: Qt.size(Number(root.profile.width || 0), Number(root.profile.height || 0)) z: 0 } + Connections { + target: ShellStore + function onPointerLockToggleRequested() { + streamVideo.relativeMouse = !streamVideo.relativeMouse + } + } + ArtworkSource { id: streamArtwork sourceUrl: root.artwork @@ -190,7 +223,11 @@ FocusScope { root.forceActiveFocus() } - onVisibleChanged: if (visible) Qt.callLater(root.restoreStreamFocus) + onVisibleChanged: { + publishCaptureRect() + if (visible) + Qt.callLater(root.restoreStreamFocus) + } onStatusVisibleChanged: Qt.callLater(root.restoreStreamFocus) Keys.onPressed: event => { diff --git a/opennow-qt/qml/screens/StreamScreen.qml b/opennow-qt/qml/screens/StreamScreen.qml index c9e72e606..0f781608e 100644 --- a/opennow-qt/qml/screens/StreamScreen.qml +++ b/opennow-qt/qml/screens/StreamScreen.qml @@ -42,15 +42,48 @@ FocusScope { + String(minutes).padStart(2, "0") + ":" + String(seconds).padStart(2, "0") } + function publishCaptureRect() { + const window = root.Window.window + if (!window) + return + const point = root.mapToItem(null, 0, 0) + ShellStore.streamCaptureRect = Qt.rect( + window.x + point.x, window.y + point.y, root.width, root.height) + } + + onXChanged: publishCaptureRect() + onYChanged: publishCaptureRect() + onWidthChanged: publishCaptureRect() + onHeightChanged: publishCaptureRect() + onVisibleChanged: publishCaptureRect() + Component.onCompleted: publishCaptureRect() + + Connections { + target: root.Window.window + function onXChanged() { root.publishCaptureRect() } + function onYChanged() { root.publishCaptureRect() } + } + Timer { interval: 1000; repeat: true; running: root.streaming; onTriggered: root.nowMs = Date.now() } - Item { + StreamVideoItem { + id: streamVideo objectName: "streamSurfaceHost" anchors.fill: parent - visible: root.visible && root.streaming && AppController.overlay === "" + visible: root.visible && root.streaming + focus: visible + inputEnabled: visible && AppController.overlay === "" + videoSize: Qt.size(Number(root.profile.width || 0), Number(root.profile.height || 0)) z: 0 } + Connections { + target: ShellStore + function onPointerLockToggleRequested() { + streamVideo.relativeMouse = !streamVideo.relativeMouse + } + } + ScreenBackground { visible: !root.streaming artwork: root.game.heroImageUrl || root.game.imageUrl || "" diff --git a/opennow-qt/qml/state/ShellStore.qml b/opennow-qt/qml/state/ShellStore.qml index c5ae7942c..eda497d5e 100644 --- a/opennow-qt/qml/state/ShellStore.qml +++ b/opennow-qt/qml/state/ShellStore.qml @@ -90,8 +90,7 @@ QtObject { property string streamPollRequestId: "" property string streamStopRequestId: "" property string streamerStartRequestId: "" - property string streamerDetectRequestId: "" - property string streamerStatusRequestId: "" + property string streamerPrepareRequestId: "" property string streamerStopRequestId: "" property bool streamerStopExpected: false property var artworkUrls: ({}) @@ -146,7 +145,12 @@ QtObject { property string streamControlRequestId: "" property string streamControlAction: "" property string streamControlMessage: "" - readonly property var streamSurface: StreamSurfaceController.surface + property rect streamCaptureRect: Qt.rect(0, 0, 0, 0) + property bool nativeRuntimeReady: false + readonly property int nativeProtocolVersion: 5 + property var nativeRuntimeCapabilities: ({}) + property int nativeRequestSequence: 0 + property var nativeRequests: ({}) property int overlayRequestGeneration: 0 property int screenshotRequestGeneration: 0 property int recordingToggleRequestGeneration: 0 @@ -175,6 +179,7 @@ QtObject { readonly property bool streamBusy: streamCreateRequestId !== "" || streamStopRequestId !== "" signal fullscreenToggleRequested() + signal pointerLockToggleRequested() readonly property var resumableSession: { if (root.activeSession) { const localStatus = Number(root.activeSession.status || 0) @@ -225,13 +230,6 @@ QtObject { onTriggered: root.controlStream("anti-afk-pulse") } - property Timer streamerStatusTimer: Timer { - interval: 1000 - repeat: true - running: false - onTriggered: root.refreshStreamerStatus() - } - property Timer artworkRetryTimer: Timer { interval: 30000 repeat: true @@ -284,28 +282,17 @@ QtObject { providersRequestId = CoreClient.request("auth.providers.list", {}, 25000) authSessionRequestId = CoreClient.request("auth.session.get", {}) activeSessionRequestId = CoreClient.request("session.active.get", {}) - if (streamerStatusRequestId === "") - streamerStatusRequestId = CoreClient.request("streamer.status.get", {}) + ensureNativeRuntimeReady() updaterStateRequestId = CoreClient.request("updater.state.get", {}) socialCapabilitiesRequestId = CoreClient.request("social.capabilities.get", {}) refreshCatalog() } function refreshStreamerDetection() { - if (!ready || streamerDetectRequestId !== "") - return - if (streamer && streamer.status && streamer.status !== "stopped" && streamer.status !== "error") { - if (streamer.capabilities) { - streamerDetection = Object.assign({}, streamerDetection, { - available: true, - capabilities: streamer.capabilities, - availableCodecs: codecNamesFromCapabilities(streamer.capabilities) - }) - } - return - } streamerDetectionMessage = qsTr("Checking native codec support…") - streamerDetectRequestId = CoreClient.request("streamer.detect", {}, 15000) + ensureNativeRuntimeReady() + if (nativeRuntimeReady) + acceptNativeCapabilities(nativeRuntimeCapabilities) } function refreshRemoteSessions() { @@ -392,6 +379,66 @@ QtObject { return result } + function acceptNativeCapabilities(capabilities) { + nativeRuntimeCapabilities = capabilities || ({}) + const codecs = codecNamesFromCapabilities(nativeRuntimeCapabilities) + streamerDetection = { + available: Boolean(nativeRuntimeCapabilities.supportsVideoDecode + && nativeRuntimeCapabilities.supportsVideoPresent), + capabilities: nativeRuntimeCapabilities, + availableCodecs: codecs + } + streamerDetectionMessage = codecs.length + ? qsTr("Available: ") + codecs.map(codec => String(codec).toUpperCase()).join(", ") + : qsTr("No native video decoder is available") + } + + function sendNativeCommand(type, params, operation) { + if (!NativeStreamRuntime.running && !NativeStreamRuntime.start()) { + const message = NativeStreamRuntime.lastError || qsTr("The embedded media runtime could not start") + lastError = message + return "" + } + nativeRequestSequence += 1 + const requestId = "qt-" + nativeRequestSequence + const command = Object.assign({id: requestId, type: type}, params || ({})) + if (!NativeStreamRuntime.send(command)) { + lastError = NativeStreamRuntime.lastError || qsTr("The embedded media runtime rejected a command") + return "" + } + const requests = Object.assign({}, nativeRequests) + requests[requestId] = Object.assign({operation: operation || type}, params || ({})) + nativeRequests = requests + return requestId + } + + function takeNativeRequest(requestId) { + const pending = nativeRequests[requestId] + if (pending === undefined) + return null + const requests = Object.assign({}, nativeRequests) + delete requests[requestId] + nativeRequests = requests + return pending + } + + function ensureNativeRuntimeReady() { + if (nativeRuntimeReady) + return + const requests = nativeRequests || ({}) + for (const requestId in requests) { + if (requests[requestId].operation === "hello") + return + } + const requestId = sendNativeCommand("hello", { + protocolVersion: nativeProtocolVersion + }, "hello") + if (requestId === "" && streamer && streamer.status === "starting") + updateStreamerFields({status: "error", + message: lastError || qsTr("The embedded media runtime could not start"), + errorCode: "streamer_start_failed"}) + } + function codecAvailable(codec) { const name = String(codec || "h264").toLowerCase() const codecs = streamerDetection && streamerDetection.availableCodecs @@ -1082,23 +1129,40 @@ QtObject { } function startNativeStreamer() { - if (!ready || !activeSession || streamerStartRequestId !== "") + if (!ready || !activeSession || streamerStartRequestId !== "" + || streamerPrepareRequestId !== "") return if (streamer && streamer.sessionId === activeSession.sessionId - && streamer.status !== "stopped" && streamer.status !== "error") + && streamer.status !== "stopped" && streamer.status !== "error" + && streamer.status !== "starting") return streamer = { status: "starting", - message: qsTr("Launching the native media runtime…"), - sessionId: activeSession.sessionId + message: qsTr("Preparing the embedded native media runtime…"), + sessionId: activeSession.sessionId, + sessionStartedAtMs: String(Date.now()), + inputReady: false, + inputPauseCount: 0, + inputResumeCount: 0, + recordingStartCount: 0, + recordingStopCount: 0, + queueDropCount: 0 } streamInputStateKnown = false streamerStopExpected = false - streamerStartRequestId = CoreClient.request("streamer.start", { - session: activeSession, - surface: streamSurface - }, 50000) - streamerStatusTimer.restart() + if (!nativeRuntimeReady) { + ensureNativeRuntimeReady() + return + } + streamerPrepareRequestId = CoreClient.request("streamer.prepare", { + session: activeSession + }, 15000) + if (streamerPrepareRequestId === "") + acceptStreamerSnapshot(Object.assign({}, streamer, { + status: "error", + message: qsTr("The core could not prepare the native stream context"), + errorCode: "core_not_ready" + })) } function retryNativeStreamer() { @@ -1110,7 +1174,6 @@ QtObject { function acceptStreamerSnapshot(snapshot) { streamer = snapshot || null if (!streamer) { - streamerStatusTimer.stop() return } inspectStreamerOverlayRequest(streamer) @@ -1126,7 +1189,6 @@ QtObject { if (activeSession && status !== "stopped" && status !== "error") { streamState = status streamMessage = streamer.message || streamMessage - streamerStatusTimer.restart() setStreamInputPaused(desiredStreamInputPaused) } if (status === "streaming") { @@ -1139,7 +1201,6 @@ QtObject { if (status !== "error" && status !== "stopped") return - streamerStatusTimer.stop() streamInputStateKnown = false if (streamRecordingActive) { streamRecordingActive = false @@ -1186,12 +1247,6 @@ QtObject { }, 35000) } - function refreshStreamerStatus() { - if (!ready || streamerStatusRequestId !== "") - return - streamerStatusRequestId = CoreClient.request("streamer.status.get", {}) - } - function artworkUrl(sourceUrl) { const source = String(sourceUrl || "") if (source === "") @@ -1270,35 +1325,36 @@ QtObject { } function stopNativeStreamer(reason) { - streamerStatusTimer.stop() - if (!ready || streamerStopRequestId !== "") + if (streamerStopRequestId !== "") return // A runtime that never started, or that already reported "stopped", has // nothing left to reap. Without this an Escape held down during a failing - // session issues one streamer.stop per key repeat. + // session issues one native stop command per key repeat. const status = streamer ? String(streamer.status || "") : "" if (status === "" || status === "stopped") return streamerStopExpected = true - streamerStopRequestId = CoreClient.request("streamer.stop", { + streamerStopRequestId = sendNativeCommand("stop", { reason: reason || "User stopped the session" - }, 10000) + }, "stop") + if (streamerStopRequestId === "") + streamerStopExpected = false } function setStreamInputPaused(paused) { desiredStreamInputPaused = Boolean(paused) - if (!ready || streamInputPauseRequestId !== "" || !streamer + if (streamInputPauseRequestId !== "" || !streamer || streamer.status === "stopped" || streamer.status === "error") return if (streamInputStateKnown && currentStreamInputPaused === desiredStreamInputPaused) return - streamInputPauseRequestId = CoreClient.request("streamer.input.pause", { + streamInputPauseRequestId = sendNativeCommand("input-paused", { paused: desiredStreamInputPaused - }, 5000) + }, "input") } function controlStream(action) { - if (!ready || streamControlRequestId !== "") + if (streamControlRequestId !== "") return if (!activeSession || !streamer || streamer.status !== "streaming") { streamControlMessage = qsTr("Stream controls are available once the session is live.") @@ -1306,9 +1362,21 @@ QtObject { } streamControlMessage = qsTr("") streamControlAction = action - streamControlRequestId = CoreClient.request("streamer.control", { - action: action - }, 5000) + const commandTypes = { + "toggle-fullscreen": "fullscreen-toggle", + "anti-afk-pulse": "anti-afk-pulse" + } + const commandType = commandTypes[action] + if (!commandType) { + streamControlAction = "" + streamControlMessage = qsTr("This stream control is unavailable") + return + } + streamControlRequestId = sendNativeCommand(commandType, {}, "control") + if (streamControlRequestId === "") { + streamControlAction = "" + streamControlMessage = lastError + } } function inspectStreamerOverlayRequest(value) { @@ -1329,8 +1397,7 @@ QtObject { } function captureStreamScreenshot() { - const rect = streamSurface && streamSurface.logicalScreenRect - ? streamSurface.logicalScreenRect : ({x: 0, y: 0, width: 0, height: 0}) + const rect = streamCaptureRect const title = selectedGame && selectedGame.title ? selectedGame.title : "OpenNOW" const path = AppController.captureScreenRegion( Number(rect.x || 0), Number(rect.y || 0), @@ -1366,8 +1433,6 @@ QtObject { antiAfkEnabled = !antiAfkEnabled streamControlMessage = antiAfkEnabled ? qsTr("Anti-AFK on") : qsTr("Anti-AFK off") accessibilityMessage = streamControlMessage - } else if (action === "toggle-microphone") { - toggleMicrophoneMode() } else if (action === "toggle-stats") { AppController.activateWindow() const compact = desktopUiActive ? "desktop-stream-stats" : "stream-stats" @@ -1380,6 +1445,8 @@ QtObject { } else if (action === "toggle-fullscreen") { AppController.activateWindow() fullscreenToggleRequested() + } else if (action === "toggle-pointer-lock") { + pointerLockToggleRequested() } else if (action === "screenshot") { captureStreamScreenshot() } else if (action === "toggle-recording") { @@ -1392,8 +1459,9 @@ QtObject { || mediaRecordingTargetRequestId !== "") return if (streamRecordingActive) { - streamRecordingStopRequestId = CoreClient.request("streamer.recording.stop", {}, 15000) - mediaMessage = qsTr("Finalizing recording…") + streamRecordingStopRequestId = sendNativeCommand("recording-stop", {}, "recording-stop") + mediaMessage = streamRecordingStopRequestId === "" + ? lastError : qsTr("Finalizing recording…") accessibilityMessage = mediaMessage return } @@ -1411,15 +1479,6 @@ QtObject { accessibilityMessage = mediaMessage } - function toggleMicrophoneMode() { - if (settings.microphoneMode !== "disabled") { - applySetting("microphoneMode", "disabled") - setSetting("microphoneMode", "disabled") - } - streamControlMessage = qsTr("Microphone upstream is unavailable for NVST sessions") - accessibilityMessage = streamControlMessage - } - function pollStreamingSession() { if (!ready || !activeSession || streamPollRequestId !== "" || streamStopRequestId !== "") return @@ -1604,6 +1663,216 @@ QtObject { focusPositions = updated } + function updateStreamerFields(fields) { + acceptStreamerSnapshot(Object.assign({}, streamer || ({}), fields || ({}))) + } + + function acceptNativeResponse(response) { + const requestId = String(response && response.id || "") + const pending = takeNativeRequest(requestId) + if (!pending) + return + const responseType = String(response.type || "") + if (responseType === "error") { + const message = String(response.message || qsTr("The embedded media runtime rejected a command")) + if (pending.operation === "hello") { + nativeRuntimeReady = false + streamerDetection = {available: false, availableCodecs: [], capabilities: ({})} + streamerDetectionMessage = message + } else if (pending.operation === "start") { + streamerStartRequestId = "" + updateStreamerFields({status: "error", message: message, + errorCode: String(response.code || "native_stream_error")}) + } else if (pending.operation === "stop") { + streamerStopRequestId = "" + lastError = message + } else if (pending.operation === "input") { + streamInputPauseRequestId = "" + } else if (pending.operation === "control") { + streamControlRequestId = "" + streamControlAction = "" + streamControlMessage = message + } else if (pending.operation === "recording-start") { + streamRecordingStartRequestId = "" + mediaMessage = message + } else if (pending.operation === "recording-stop") { + streamRecordingStopRequestId = "" + mediaMessage = message + } + lastError = message + return + } + + if (pending.operation === "hello") { + const protocolVersion = Number(response.capabilities + && response.capabilities.protocolVersion || 0) + nativeRuntimeReady = responseType === "ready" + && protocolVersion === nativeProtocolVersion + if (!nativeRuntimeReady) { + lastError = qsTr("The embedded media runtime returned an invalid handshake") + streamerDetection = {available: false, availableCodecs: [], capabilities: ({})} + streamerDetectionMessage = lastError + return + } + acceptNativeCapabilities(response.capabilities || ({})) + if (activeSession && (!streamer || streamer.status === "starting")) + Qt.callLater(() => root.startNativeStreamer()) + } else if (pending.operation === "start") { + streamerStartRequestId = "" + updateStreamerFields({ + status: "streaming", + message: qsTr("Native-owned NVST media transport is active"), + transport: String(response.transport || "nvst"), + capabilities: Object.assign({}, nativeRuntimeCapabilities, + response.capabilities || ({})), + errorCode: null + }) + } else if (pending.operation === "stop") { + streamerStopRequestId = "" + updateStreamerFields({status: "stopped", message: pending.reason || qsTr("Stream stopped"), + sessionId: null, transport: null, inputReady: false}) + } else if (pending.operation === "input") { + streamInputPauseRequestId = "" + currentStreamInputPaused = Boolean(pending.paused) + streamInputStateKnown = true + const counter = currentStreamInputPaused ? "inputPauseCount" : "inputResumeCount" + const values = {} + values[counter] = Number(streamer && streamer[counter] || 0) + 1 + updateStreamerFields(values) + if (currentStreamInputPaused !== desiredStreamInputPaused) + Qt.callLater(() => root.setStreamInputPaused(root.desiredStreamInputPaused)) + } else if (pending.operation === "control") { + streamControlRequestId = "" + streamControlAction = "" + streamControlMessage = qsTr("Stream control applied") + } else if (pending.operation === "recording-start") { + streamRecordingStartRequestId = "" + streamRecordingActive = true + streamRecordingStartedAtMs = Date.now() + streamRecordingElapsedMs = 0 + updateStreamerFields({recordingStartCount: Number(streamer && streamer.recordingStartCount || 0) + 1}) + const rect = streamCaptureRect + if (pendingRecordingThumbnailPath) { + AppController.captureScreenRegionTo( + Number(rect.x || 0), Number(rect.y || 0), + Number(rect.width || 0), Number(rect.height || 0), + pendingRecordingThumbnailPath) + } + mediaMessage = qsTr("Recording source video + stream audio") + accessibilityMessage = qsTr("Recording started") + } else if (pending.operation === "recording-stop") { + streamRecordingStopRequestId = "" + streamRecordingActive = false + streamRecordingElapsedMs = 0 + pendingRecordingPath = "" + pendingRecordingThumbnailPath = "" + updateStreamerFields({recordingStopCount: Number(streamer && streamer.recordingStopCount || 0) + 1}) + mediaMessage = response.path ? qsTr("Recording saved") : qsTr("Recording stopped") + accessibilityMessage = response.path + ? qsTr("Recording saved to %1").arg(response.path) : mediaMessage + refreshMedia() + } + } + + function acceptNativeEvent(event) { + const type = String(event && event.type || "") + const fields = {} + if (event.framesPerSecond !== undefined) + fields.framesPerSecond = Number(event.framesPerSecond) + if (event.bitrateMbps !== undefined) + fields.bitrateMbps = Number(event.bitrateMbps) + if (event.peakBitrateMbps !== undefined) + fields.peakBitrateMbps = Number(event.peakBitrateMbps) + if (event.event === "first-frame") { + if (streamer && (streamer.firstFrameLatencyMs === undefined + || streamer.firstFrameLatencyMs === null)) { + fields.firstFrameLatencyMs = Math.max(0, + Date.now() - Number(streamer.sessionStartedAtMs || Date.now())) + } + fields.mediaBackend = String(event.backend || "") + } else if (event.event === "backend-fallback") { + fields.backendFallbackCount = Number(streamer && streamer.backendFallbackCount || 0) + 1 + } else if (event.event === "decoder-error") { + fields.decoderErrorCount = Number(streamer && streamer.decoderErrorCount || 0) + 1 + } else if (event.event === "output-error") { + fields.outputErrorCount = Number(streamer && streamer.outputErrorCount || 0) + 1 + } else if (event.event === "device-state") { + const key = event.recovered ? "deviceRecoveryCount" : "deviceLossCount" + fields[key] = Number(streamer && streamer[key] || 0) + 1 + } else if (event.event === "queue-dropped") { + fields.queueDropCount = Number(streamer && streamer.queueDropCount || 0) + + Number(event.count || 0) + } + + if (type === "status") { + fields.status = event.status === "ready" ? "streaming" : String(event.status || "streaming") + fields.message = String(event.message || streamMessage) + } else if (type === "error") { + fields.status = "error" + fields.message = String(event.message || qsTr("Native media runtime failed")) + fields.errorCode = String(event.code || "native_stream_error") + sendNativeCommand("stop", {reason: fields.message}, "error-stop") + } else if (type === "input-ready") { + fields.inputReady = true + fields.inputUnavailableReason = null + } else if (type === "input-unavailable") { + fields.inputReady = false + fields.inputUnavailableReason = String(event.reason || "") + } else if (type === "recording-state") { + if (event.state === "saved" || event.state === "failed") { + streamRecordingActive = false + streamRecordingElapsedMs = 0 + mediaMessage = event.state === "saved" + ? qsTr("Recording saved") + : String(event.message || qsTr("Recording failed")) + if (event.state === "failed") + lastError = mediaMessage + refreshMedia() + } + } else if (type === "overlay-request") { + fields.overlayRequestGeneration = Number(streamer && streamer.overlayRequestGeneration || 0) + 1 + } else if (type === "screenshot-request") { + fields.screenshotRequestGeneration = Number(streamer && streamer.screenshotRequestGeneration || 0) + 1 + } else if (type === "recording-toggle-request") { + fields.recordingToggleRequestGeneration = Number(streamer && streamer.recordingToggleRequestGeneration || 0) + 1 + } else if (type === "shortcut-action") { + fields.shortcutActionGeneration = Number(streamer && streamer.shortcutActionGeneration || 0) + 1 + fields.shortcutAction = String(event.action || "") + if (fields.shortcutAction === "toggle-stats") + fields.statsToggleCount = Number(streamer && streamer.statsToggleCount || 0) + 1 + else if (fields.shortcutAction === "toggle-fullscreen") + fields.fullscreenToggleCount = Number(streamer && streamer.fullscreenToggleCount || 0) + 1 + } + updateStreamerFields(fields) + } + + property Connections nativeRuntimeConnections: Connections { + target: NativeStreamRuntime + function onResponseReceived(response) { root.acceptNativeResponse(response) } + function onEventReceived(event) { root.acceptNativeEvent(event) } + function onCallbacksDropped(count) { + root.updateStreamerFields({queueDropCount: + Number(root.streamer && root.streamer.queueDropCount || 0) + Number(count || 0)}) + } + function onRunningChanged() { + if (NativeStreamRuntime.running) + return + root.nativeRuntimeReady = false + root.nativeRequests = ({}) + root.streamerStartRequestId = "" + root.streamerStopRequestId = "" + root.streamInputPauseRequestId = "" + root.streamControlRequestId = "" + root.streamRecordingStartRequestId = "" + root.streamRecordingStopRequestId = "" + if (root.streamer && root.streamer.status !== "stopped" + && root.streamer.status !== "error") + root.updateStreamerFields({status: "error", + message: NativeStreamRuntime.lastError || qsTr("The embedded media runtime stopped"), + errorCode: "streamer_closed"}) + } + } + property Connections coreConnections: Connections { target: CoreClient function onStateChanged() { @@ -1644,13 +1913,6 @@ QtObject { } else { root.beginConsoleSurfacePersistence() } - } else if (requestId === root.streamerDetectRequestId) { - root.streamerDetectRequestId = "" - root.streamerDetection = result - const codecs = result.availableCodecs || [] - root.streamerDetectionMessage = codecs.length - ? qsTr("Available: ") + codecs.map(codec => String(codec).toUpperCase()).join(", ") - : qsTr("No native video decoder is available") } else if (requestId === root.providersRequestId) { root.providers = result.providers || [] root.providersRequestId = "" @@ -1870,35 +2132,14 @@ QtObject { root.mediaRecordingTargetRequestId = "" root.pendingRecordingPath = String(result.path || "") root.pendingRecordingThumbnailPath = String(result.thumbnailPath || "") - root.streamRecordingStartRequestId = CoreClient.request("streamer.recording.start", { + root.streamRecordingStartRequestId = root.sendNativeCommand("recording-start", { outputPath: root.pendingRecordingPath - }, 15000) - } else if (requestId === root.streamRecordingStartRequestId) { - root.streamRecordingStartRequestId = "" - root.streamRecordingActive = true - root.streamRecordingStartedAtMs = Date.now() - root.streamRecordingElapsedMs = 0 - const rect = root.streamSurface && root.streamSurface.logicalScreenRect - ? root.streamSurface.logicalScreenRect : ({x: 0, y: 0, width: 0, height: 0}) - if (root.pendingRecordingThumbnailPath) { - AppController.captureScreenRegionTo( - Number(rect.x || 0), Number(rect.y || 0), - Number(rect.width || 0), Number(rect.height || 0), - root.pendingRecordingThumbnailPath) + }, "recording-start") + if (root.streamRecordingStartRequestId === "") { + root.pendingRecordingPath = "" + root.pendingRecordingThumbnailPath = "" + root.mediaMessage = root.lastError } - root.mediaMessage = qsTr("Recording source video + stream audio") - root.accessibilityMessage = qsTr("Recording started") - } else if (requestId === root.streamRecordingStopRequestId) { - root.streamRecordingStopRequestId = "" - root.streamRecordingActive = false - root.streamRecordingElapsedMs = 0 - root.pendingRecordingPath = "" - root.pendingRecordingThumbnailPath = "" - root.mediaMessage = result.path ? qsTr("Recording saved") : qsTr("Recording stopped") - root.accessibilityMessage = result.path - ? qsTr("Recording saved to %1").arg(result.path) - : root.mediaMessage - root.refreshMedia() } else if (requestId === root.diagnosticsRequestId) { root.diagnosticsRequestId = "" root.diagnostics = result @@ -1995,28 +2236,25 @@ QtObject { if (!wasForceNewAfterStop && Boolean(root.settings.showSessionReport) && root.lastSessionReport) AppController.showOverlay("session-report") - } else if (requestId === root.streamerStartRequestId) { - root.streamerStartRequestId = "" - root.acceptStreamerSnapshot(result.streamer || result || null) - } else if (requestId === root.streamerStatusRequestId) { - root.streamerStatusRequestId = "" - root.acceptStreamerSnapshot(result.streamer || result || root.streamer) - } else if (requestId === root.streamerStopRequestId) { - root.streamerStopRequestId = "" - root.acceptStreamerSnapshot(result.streamer || result || null) - } else if (requestId === root.streamInputPauseRequestId) { - root.streamInputPauseRequestId = "" - if (result.streamerRunning) - root.currentStreamInputPaused = Boolean(result.paused) - root.streamInputStateKnown = Boolean(result.streamerRunning) - if (result.streamerRunning - && root.currentStreamInputPaused !== root.desiredStreamInputPaused) - Qt.callLater(() => root.setStreamInputPaused(root.desiredStreamInputPaused)) - } else if (requestId === root.streamControlRequestId) { - root.streamControlRequestId = "" - root.streamControlAction = "" - root.streamControlMessage = qsTr("Stream control applied") - root.refreshStreamerStatus() + } else if (requestId === root.streamerPrepareRequestId) { + root.streamerPrepareRequestId = "" + if (!result.context) { + root.acceptStreamerSnapshot(Object.assign({}, root.streamer || ({}), { + status: "error", + message: qsTr("The core returned an invalid embedded stream context"), + errorCode: "invalid_stream_context" + })) + return + } + root.streamerStartRequestId = root.sendNativeCommand("start", { + context: result.context + }, "start") + if (root.streamerStartRequestId === "") + root.acceptStreamerSnapshot(Object.assign({}, root.streamer || ({}), { + status: "error", + message: NativeStreamRuntime.lastError || qsTr("The embedded media runtime could not start the stream"), + errorCode: "streamer_start_failed" + })) } } function onRequestFailed(requestId, code, message) { @@ -2039,10 +2277,6 @@ QtObject { } else if (requestId === root.catalogRequestId) { root.catalogState = "error" root.catalogRequestId = "" - } else if (requestId === root.streamerDetectRequestId) { - root.streamerDetectRequestId = "" - root.streamerDetection = {available: false, availableCodecs: [], capabilities: ({})} - root.streamerDetectionMessage = message } else if (requestId === root.providersRequestId) { root.providersRequestId = "" } else if (requestId === root.authSessionRequestId) { @@ -2115,20 +2349,6 @@ QtObject { root.pendingRecordingPath = "" root.pendingRecordingThumbnailPath = "" root.mediaMessage = message - } else if (requestId === root.streamRecordingStartRequestId) { - root.streamRecordingStartRequestId = "" - root.pendingRecordingPath = "" - root.pendingRecordingThumbnailPath = "" - root.streamRecordingActive = false - root.mediaMessage = message - } else if (requestId === root.streamRecordingStopRequestId) { - root.streamRecordingStopRequestId = "" - root.streamRecordingActive = false - root.streamRecordingElapsedMs = 0 - root.pendingRecordingPath = "" - root.pendingRecordingThumbnailPath = "" - root.mediaMessage = message - root.refreshMedia() } else if (requestId === root.diagnosticsRequestId) { root.diagnosticsRequestId = "" root.diagnosticsMessage = message @@ -2215,21 +2435,9 @@ QtObject { root.streamStopRequestId = "" root.streamState = "error" root.streamMessage = message - } else if (requestId === root.streamerStartRequestId) { - root.streamerStartRequestId = "" - root.streamerStatusTimer.stop() + } else if (requestId === root.streamerPrepareRequestId) { + root.streamerPrepareRequestId = "" root.acceptStreamerSnapshot({ status: "error", message: message, errorCode: code }) - } else if (requestId === root.streamerStatusRequestId) { - root.streamerStatusRequestId = "" - } else if (requestId === root.streamerStopRequestId) { - root.streamerStopRequestId = "" - root.lastError = message - } else if (requestId === root.streamInputPauseRequestId) { - root.streamInputPauseRequestId = "" - } else if (requestId === root.streamControlRequestId) { - root.streamControlRequestId = "" - root.streamControlAction = "" - root.streamControlMessage = message } } function onEventReceived(name, payload) { diff --git a/opennow-qt/src/ControllerInput.cpp b/opennow-qt/src/ControllerInput.cpp index 88e796a9f..430fbee0a 100644 --- a/opennow-qt/src/ControllerInput.cpp +++ b/opennow-qt/src/ControllerInput.cpp @@ -3,15 +3,18 @@ #include #include #include +#include #include -#include +#include namespace { constexpr Sint16 axisPressThreshold = 18000; constexpr Sint16 axisReleaseThreshold = 12000; constexpr qint64 repeatDelayMs = 280; constexpr qint64 repeatIntervalMs = 85; +constexpr qint64 gamepadKeepaliveMs = 100; +constexpr quint32 guideLocalAction = 1; } ControllerInput::ControllerInput(QObject *parent) @@ -26,9 +29,7 @@ ControllerInput::ControllerInput(QObject *parent) int count = 0; if (auto *ids = SDL_GetGamepads(&count)) { - for (int index = 0; index < count; ++index) { - openController(ids[index]); - } + for (int index = 0; index < count; ++index) openController(ids[index]); SDL_free(ids); } @@ -42,38 +43,34 @@ ControllerInput::ControllerInput(QObject *parent) ControllerInput::~ControllerInput() { m_pollTimer.stop(); - for (auto *gamepad : std::as_const(m_gamepads)) { - SDL_CloseGamepad(gamepad); - } - m_gamepads.clear(); - if (m_sdlReady) { - SDL_QuitSubSystem(SDL_INIT_GAMEPAD); + for (auto &slot : m_slots) { + if (slot.gamepad) SDL_CloseGamepad(slot.gamepad); + slot = {}; } + m_gamepadSlots.clear(); + if (m_sdlReady) SDL_QuitSubSystem(SDL_INIT_GAMEPAD); } int ControllerInput::controllerCount() const { - return m_gamepads.size(); + return m_gamepadSlots.size(); } QVariantList ControllerInput::controllers() const { - auto ids = m_gamepads.keys(); - std::sort(ids.begin(), ids.end()); QVariantList result; - result.reserve(ids.size()); - for (qsizetype index = 0; index < ids.size(); ++index) { - auto *gamepad = m_gamepads.value(ids.at(index)); - if (!gamepad) { - continue; - } + result.reserve(m_gamepadSlots.size()); + for (qsizetype index = 0; index < static_cast(m_slots.size()); ++index) { + const auto &slot = m_slots[static_cast(index)]; + if (!slot.gamepad) continue; int batteryPercent = -1; - const auto power = SDL_GetGamepadPowerInfo(gamepad, &batteryPercent); - const auto *name = SDL_GetGamepadName(gamepad); + const auto power = SDL_GetGamepadPowerInfo(slot.gamepad, &batteryPercent); + const auto *name = SDL_GetGamepadName(slot.gamepad); result.push_back(QVariantMap{ {QStringLiteral("slot"), index + 1}, - {QStringLiteral("instanceId"), static_cast(ids.at(index))}, - {QStringLiteral("name"), name ? QString::fromUtf8(name) : QStringLiteral("Game controller")}, + {QStringLiteral("instanceId"), static_cast(slot.instanceId)}, + {QStringLiteral("name"), name ? QString::fromUtf8(name) + : QStringLiteral("Game controller")}, {QStringLiteral("batteryPercent"), batteryPercent}, {QStringLiteral("charging"), power == SDL_POWERSTATE_CHARGING}, }); @@ -88,11 +85,15 @@ bool ControllerInput::shellCaptureEnabled() const void ControllerInput::setShellCaptureEnabled(bool enabled) { - if (m_shellCaptureEnabled == enabled) { - return; - } + if (m_shellCaptureEnabled == enabled) return; + if (enabled) publishConnectedGamepads(true); m_shellCaptureEnabled = enabled; if (!enabled) { + for (int slot = 0; slot < static_cast(m_slots.size()); ++slot) + updateSlotSnapshot(slot); + publishConnectedGamepads(); + m_lastGamepadSnapshotAt = m_clock.elapsed(); + } else { for (auto *direction : {&m_left, &m_right, &m_up, &m_down}) { direction->active = false; direction->pressedAt = 0; @@ -127,62 +128,95 @@ void ControllerInput::poll() } } - dispatchRepeats(m_clock.elapsed()); - if (m_clock.elapsed() - m_lastControllerMetadataAt >= 2'000) { - m_lastControllerMetadataAt = m_clock.elapsed(); + const auto now = m_clock.elapsed(); + dispatchRepeats(now); + if (!m_shellCaptureEnabled && now - m_lastGamepadSnapshotAt >= gamepadKeepaliveMs) { + publishConnectedGamepads(); + m_lastGamepadSnapshotAt = now; + } + if (now - m_lastControllerMetadataAt >= 2'000) { + m_lastControllerMetadataAt = now; emit controllersChanged(); } } void ControllerInput::openController(SDL_JoystickID id) { - if (m_gamepads.contains(id)) { - return; - } - if (auto *gamepad = SDL_OpenGamepad(id)) { - m_gamepads.insert(id, gamepad); - emit controllerCountChanged(m_gamepads.size()); - emit controllersChanged(); - } else { + if (m_gamepadSlots.contains(id)) return; + const auto freeSlot = std::find_if(m_slots.begin(), m_slots.end(), + [](const GamepadSlot &slot) { return !slot.gamepad; }); + if (freeSlot == m_slots.end()) return; + auto *gamepad = SDL_OpenGamepad(id); + if (!gamepad) { qWarning("Could not open SDL gamepad %u: %s", id, SDL_GetError()); + return; } + const auto slot = static_cast(std::distance(m_slots.begin(), freeSlot)); + *freeSlot = {.gamepad = gamepad, .instanceId = id}; + m_gamepadSlots.insert(id, slot); + updateSlotSnapshot(slot); + if (!m_shellCaptureEnabled) publishGamepad(slot); + emit controllerCountChanged(m_gamepadSlots.size()); + emit controllersChanged(); } void ControllerInput::closeController(SDL_JoystickID id) { - auto *gamepad = m_gamepads.take(id); - if (!gamepad) { - return; - } - SDL_CloseGamepad(gamepad); + if (!m_gamepadSlots.contains(id)) return; + const auto slotIndex = m_gamepadSlots.take(id); + if (slotIndex < 0 || slotIndex >= static_cast(m_slots.size())) return; + auto &slot = m_slots[static_cast(slotIndex)]; + if (!slot.gamepad) return; + SDL_CloseGamepad(slot.gamepad); + slot = {}; + if (!m_shellCaptureEnabled) publishGamepad(slotIndex, true); for (auto *direction : {&m_left, &m_right, &m_up, &m_down}) { direction->active = false; direction->pressedAt = 0; direction->repeatedAt = 0; } - emit controllerCountChanged(m_gamepads.size()); + emit controllerCountChanged(m_gamepadSlots.size()); emit controllersChanged(); } void ControllerInput::handleButton(const SDL_GamepadButtonEvent &event, bool pressed) { - if (!m_shellCaptureEnabled) { - return; + const auto slotIndex = m_gamepadSlots.value(event.which, -1); + if (slotIndex < 0) return; + auto &slot = m_slots[static_cast(slotIndex)]; + if (event.button == SDL_GAMEPAD_BUTTON_GUIDE) { + if (pressed && !m_shellCaptureEnabled) + emit localActionRequested(guideLocalAction); + } else if (const auto mask = buttonMask(event.button); mask != 0) { + if (pressed) slot.buttons |= mask; + else slot.buttons &= static_cast(~mask); + if (!m_shellCaptureEnabled) publishGamepad(slotIndex); } + + if (!m_shellCaptureEnabled) return; const auto key = keyForButton(event.button); if (key != 0) { postKey(key, pressed); - if (pressed) { - emit controllerActivity(); - } + if (pressed) emit controllerActivity(); } } void ControllerInput::handleAxis(const SDL_GamepadAxisEvent &event) { - if (!m_shellCaptureEnabled) { - return; + const auto slotIndex = m_gamepadSlots.value(event.which, -1); + if (slotIndex < 0) return; + auto &slot = m_slots[static_cast(slotIndex)]; + switch (event.axis) { + case SDL_GAMEPAD_AXIS_LEFTX: slot.rawLeftX = event.value; break; + case SDL_GAMEPAD_AXIS_LEFTY: slot.rawLeftY = event.value; break; + case SDL_GAMEPAD_AXIS_RIGHTX: slot.rawRightX = event.value; break; + case SDL_GAMEPAD_AXIS_RIGHTY: slot.rawRightY = event.value; break; + case SDL_GAMEPAD_AXIS_LEFT_TRIGGER: slot.leftTrigger = triggerValue(event.value); break; + case SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: slot.rightTrigger = triggerValue(event.value); break; + default: return; } + if (!m_shellCaptureEnabled) publishGamepad(slotIndex); + if (!m_shellCaptureEnabled) return; const auto value = event.value; if (event.axis == SDL_GAMEPAD_AXIS_LEFTX) { @@ -194,11 +228,97 @@ void ControllerInput::handleAxis(const SDL_GamepadAxisEvent &event) } } -void ControllerInput::updateDirection(RepeatingDirection &direction, bool active) +quint16 ControllerInput::gamepadBitmap() const { - if (direction.active == active) { - return; + quint16 bitmap = 0; + for (int slot = 0; slot < static_cast(m_slots.size()); ++slot) { + if (m_slots[static_cast(slot)].gamepad) + bitmap |= static_cast((1u << slot) | (1u << (slot + 8))); } + return bitmap; +} + +void ControllerInput::publishGamepad(int slotIndex, bool neutral) +{ + const auto &slot = m_slots[static_cast(slotIndex)]; + const auto left = neutral ? QPair{} : radialDeadzone(slot.rawLeftX, slot.rawLeftY); + const auto right = neutral ? QPair{} : radialDeadzone(slot.rawRightX, slot.rawRightY); + emit gamepadSnapshot(static_cast(slotIndex), gamepadBitmap(), + neutral ? 0 : slot.buttons, + neutral ? 0 : slot.leftTrigger, neutral ? 0 : slot.rightTrigger, + left.first, static_cast(-left.second), + right.first, static_cast(-right.second)); +} + +void ControllerInput::publishConnectedGamepads(bool neutral) +{ + for (int slot = 0; slot < static_cast(m_slots.size()); ++slot) { + if (m_slots[static_cast(slot)].gamepad) publishGamepad(slot, neutral); + } +} + +void ControllerInput::updateSlotSnapshot(int slotIndex) +{ + auto &slot = m_slots[static_cast(slotIndex)]; + if (!slot.gamepad) return; + slot.buttons = 0; + for (int button = 0; button < SDL_GAMEPAD_BUTTON_COUNT; ++button) { + if (SDL_GetGamepadButton(slot.gamepad, static_cast(button))) + slot.buttons |= buttonMask(static_cast(button)); + } + slot.rawLeftX = SDL_GetGamepadAxis(slot.gamepad, SDL_GAMEPAD_AXIS_LEFTX); + slot.rawLeftY = SDL_GetGamepadAxis(slot.gamepad, SDL_GAMEPAD_AXIS_LEFTY); + slot.rawRightX = SDL_GetGamepadAxis(slot.gamepad, SDL_GAMEPAD_AXIS_RIGHTX); + slot.rawRightY = SDL_GetGamepadAxis(slot.gamepad, SDL_GAMEPAD_AXIS_RIGHTY); + slot.leftTrigger = triggerValue( + SDL_GetGamepadAxis(slot.gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER)); + slot.rightTrigger = triggerValue( + SDL_GetGamepadAxis(slot.gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)); +} + +quint16 ControllerInput::buttonMask(Uint8 button) +{ + switch (button) { + case SDL_GAMEPAD_BUTTON_SOUTH: return 0x1000; + case SDL_GAMEPAD_BUTTON_EAST: return 0x2000; + case SDL_GAMEPAD_BUTTON_WEST: return 0x4000; + case SDL_GAMEPAD_BUTTON_NORTH: return 0x8000; + case SDL_GAMEPAD_BUTTON_BACK: return 0x0020; + case SDL_GAMEPAD_BUTTON_START: return 0x0010; + case SDL_GAMEPAD_BUTTON_LEFT_STICK: return 0x0040; + case SDL_GAMEPAD_BUTTON_RIGHT_STICK: return 0x0080; + case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: return 0x0100; + case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: return 0x0200; + case SDL_GAMEPAD_BUTTON_DPAD_UP: return 0x0001; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: return 0x0002; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: return 0x0004; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: return 0x0008; + default: return 0; + } +} + +QPair ControllerInput::radialDeadzone(qint16 x, qint16 y) +{ + constexpr double deadzone = 0.15; + const auto normalizedX = static_cast(x) / 32767.0; + const auto normalizedY = static_cast(y) / 32767.0; + const auto magnitude = std::hypot(normalizedX, normalizedY); + if (magnitude < deadzone) return {}; + const auto scaled = std::clamp((magnitude - deadzone) / (1.0 - deadzone), 0.0, 1.0); + const auto factor = scaled / magnitude; + return {static_cast(std::round(normalizedX * factor * 32767.0)), + static_cast(std::round(normalizedY * factor * 32767.0))}; +} + +quint8 ControllerInput::triggerValue(qint16 value) +{ + return static_cast(std::round( + std::clamp(static_cast(value) / 32767.0, 0.0, 1.0) * 255.0)); +} + +void ControllerInput::updateDirection(RepeatingDirection &direction, bool active) +{ + if (direction.active == active) return; direction.active = active; if (active) { direction.pressedAt = m_clock.elapsed(); @@ -213,9 +333,7 @@ void ControllerInput::dispatchRepeats(qint64 now) { for (auto *direction : {&m_left, &m_right, &m_up, &m_down}) { if (!direction->active || now - direction->pressedAt < repeatDelayMs - || now - direction->repeatedAt < repeatIntervalMs) { - continue; - } + || now - direction->repeatedAt < repeatIntervalMs) continue; direction->repeatedAt = now; postKey(direction->key, true, true); postKey(direction->key, false, true); @@ -224,50 +342,31 @@ void ControllerInput::dispatchRepeats(qint64 now) void ControllerInput::postKey(int key, bool pressed, bool autoRepeat) { - if (!m_shellCaptureEnabled) { - return; - } + if (!m_shellCaptureEnabled) return; auto *target = QGuiApplication::focusObject(); - if (!target) { - target = QCoreApplication::instance(); - } + if (!target) target = QCoreApplication::instance(); const auto type = pressed ? QEvent::KeyPress : QEvent::KeyRelease; QCoreApplication::postEvent( - target, - new QKeyEvent(type, key, Qt::NoModifier, syntheticControllerScanCode, 0, 0, QString(), - autoRepeat)); + target, new QKeyEvent(type, key, Qt::NoModifier, syntheticControllerScanCode, + 0, 0, QString(), autoRepeat)); } int ControllerInput::keyForButton(Uint8 button) { switch (button) { - case SDL_GAMEPAD_BUTTON_SOUTH: - return Qt::Key_Return; - case SDL_GAMEPAD_BUTTON_EAST: - return Qt::Key_Escape; - case SDL_GAMEPAD_BUTTON_WEST: - return Qt::Key_X; - case SDL_GAMEPAD_BUTTON_NORTH: - return Qt::Key_Y; - case SDL_GAMEPAD_BUTTON_DPAD_LEFT: - return Qt::Key_Left; - case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: - return Qt::Key_Right; - case SDL_GAMEPAD_BUTTON_DPAD_UP: - return Qt::Key_Up; - case SDL_GAMEPAD_BUTTON_DPAD_DOWN: - return Qt::Key_Down; - case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: - return Qt::Key_PageUp; - case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: - return Qt::Key_PageDown; - case SDL_GAMEPAD_BUTTON_START: - return Qt::Key_Menu; - case SDL_GAMEPAD_BUTTON_BACK: - return Qt::Key_Back; - case SDL_GAMEPAD_BUTTON_GUIDE: - return Qt::Key_F1; - default: - return 0; + case SDL_GAMEPAD_BUTTON_SOUTH: return Qt::Key_Return; + case SDL_GAMEPAD_BUTTON_EAST: return Qt::Key_Escape; + case SDL_GAMEPAD_BUTTON_WEST: return Qt::Key_X; + case SDL_GAMEPAD_BUTTON_NORTH: return Qt::Key_Y; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: return Qt::Key_Left; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: return Qt::Key_Right; + case SDL_GAMEPAD_BUTTON_DPAD_UP: return Qt::Key_Up; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: return Qt::Key_Down; + case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: return Qt::Key_PageUp; + case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: return Qt::Key_PageDown; + case SDL_GAMEPAD_BUTTON_START: return Qt::Key_Menu; + case SDL_GAMEPAD_BUTTON_BACK: return Qt::Key_Back; + case SDL_GAMEPAD_BUTTON_GUIDE: return Qt::Key_F1; + default: return 0; } } diff --git a/opennow-qt/src/ControllerInput.h b/opennow-qt/src/ControllerInput.h index 6ff87c1c9..cb6d141a1 100644 --- a/opennow-qt/src/ControllerInput.h +++ b/opennow-qt/src/ControllerInput.h @@ -6,6 +6,8 @@ #include #include +#include + #include class ControllerInput final : public QObject @@ -31,6 +33,11 @@ class ControllerInput final : public QObject void controllersChanged(); void shellCaptureEnabledChanged(); void controllerActivity(); + void gamepadSnapshot(quint8 controllerId, quint16 bitmap, quint16 buttons, + quint8 leftTrigger, quint8 rightTrigger, + qint16 leftStickX, qint16 leftStickY, + qint16 rightStickX, qint16 rightStickY); + void localActionRequested(quint32 action); private slots: void poll(); @@ -43,6 +50,18 @@ private slots: int key = 0; }; + struct GamepadSlot { + SDL_Gamepad *gamepad = nullptr; + SDL_JoystickID instanceId = 0; + quint16 buttons = 0; + qint16 rawLeftX = 0; + qint16 rawLeftY = 0; + qint16 rawRightX = 0; + qint16 rawRightY = 0; + quint8 leftTrigger = 0; + quint8 rightTrigger = 0; + }; + void openController(SDL_JoystickID id); void closeController(SDL_JoystickID id); void handleButton(const SDL_GamepadButtonEvent &event, bool pressed); @@ -51,13 +70,22 @@ private slots: void dispatchRepeats(qint64 now); void postKey(int key, bool pressed, bool autoRepeat = false); static int keyForButton(Uint8 button); + [[nodiscard]] quint16 gamepadBitmap() const; + void publishGamepad(int slot, bool neutral = false); + void publishConnectedGamepads(bool neutral = false); + void updateSlotSnapshot(int slot); + static quint16 buttonMask(Uint8 button); + static QPair radialDeadzone(qint16 x, qint16 y); + static quint8 triggerValue(qint16 value); QTimer m_pollTimer; QElapsedTimer m_clock; - QHash m_gamepads; + QHash m_gamepadSlots; + std::array m_slots; bool m_sdlReady = false; bool m_shellCaptureEnabled = true; qint64 m_lastControllerMetadataAt = 0; + qint64 m_lastGamepadSnapshotAt = 0; RepeatingDirection m_left{false, 0, 0, Qt::Key_Left}; RepeatingDirection m_right{false, 0, 0, Qt::Key_Right}; RepeatingDirection m_up{false, 0, 0, Qt::Key_Up}; diff --git a/opennow-qt/src/NativeStreamRuntime.cpp b/opennow-qt/src/NativeStreamRuntime.cpp new file mode 100644 index 000000000..03cb41693 --- /dev/null +++ b/opennow-qt/src/NativeStreamRuntime.cpp @@ -0,0 +1,576 @@ +#include "NativeStreamRuntime.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace Qt::StringLiterals; + +namespace { +constexpr std::size_t CommandQueueCapacity = 128; +constexpr std::size_t ResponseQueueCapacity = 128; +constexpr std::size_t EventQueueCapacity = 256; + +QString statusText(OpenNowStreamerStatus status) +{ + switch (status) { + case OPENNOW_STREAMER_OK: + return {}; + case OPENNOW_STREAMER_NULL_POINTER: + return u"The embedded streamer rejected a null pointer."_s; + case OPENNOW_STREAMER_INVALID_CONFIG: + return u"The embedded streamer rejected its ABI configuration."_s; + case OPENNOW_STREAMER_MESSAGE_TOO_LARGE: + return u"The embedded streamer command is too large."_s; + case OPENNOW_STREAMER_QUEUE_FULL: + return u"The embedded streamer command queue is full."_s; + case OPENNOW_STREAMER_CLOSED: + return u"The embedded streamer is closed."_s; + case OPENNOW_STREAMER_NO_FRAME: + return u"The embedded streamer has no decoded frame available."_s; + case OPENNOW_STREAMER_GRAPHICS_UNAVAILABLE: + return u"The embedded streamer graphics context is unavailable."_s; + case OPENNOW_STREAMER_WRONG_THREAD: + return u"The embedded streamer graphics call ran on the wrong thread."_s; + case OPENNOW_STREAMER_STALE_FRAME: + return u"The embedded streamer rejected a stale frame."_s; + case OPENNOW_STREAMER_RENDER_FAILED: + return u"The embedded streamer could not record the frame."_s; + case OPENNOW_STREAMER_SCENE_GRAPH_ACTIVE: + return u"The embedded streamer scene graph is still active."_s; + case OPENNOW_STREAMER_FRAME_ALREADY_RECORDED: + return u"The embedded streamer frame was already recorded."_s; + case OPENNOW_STREAMER_PANIC: + return u"The embedded streamer caught an internal panic."_s; + } + return u"The embedded streamer returned an unknown status (%1)."_s + .arg(static_cast(status)); +} +} + +struct NativeStreamRuntime::CallbackState final + : std::enable_shared_from_this +{ + struct Message { + QByteArray bytes; + bool event = false; + bool cursor = false; + }; + + std::mutex mutex; + QQueue pending; + QPointer target; + int dropped = 0; + bool accepting = true; + bool drainScheduled = false; + bool framePending = false; +}; + +struct NativeStreamRuntime::Private { + explicit Private(Api runtimeApi) + : api(runtimeApi) + { + } + + Api api; + std::mutex handleMutex; + OpenNowStreamer *handle = nullptr; + std::shared_ptr callbackState; + QString lastError; + bool graphicsActive = false; +}; + +NativeStreamRuntime::NativeStreamRuntime(QObject *parent) + : NativeStreamRuntime(Api{&opennow_streamer_create, + &opennow_streamer_send, + &opennow_streamer_destroy, + &opennow_streamer_set_graphics_context, + &opennow_streamer_acquire_latest_frame, + &opennow_streamer_record_frame, + &opennow_streamer_release_frame, + &opennow_streamer_scene_graph_shutdown, + &opennow_streamer_submit_key, + &opennow_streamer_submit_mouse_relative, + &opennow_streamer_submit_mouse_absolute, + &opennow_streamer_submit_mouse_button, + &opennow_streamer_submit_mouse_wheel, + &opennow_streamer_submit_gamepad, + &opennow_streamer_submit_local_action, + &opennow_streamer_set_capture_active}, parent) +{ +} + +NativeStreamRuntime::NativeStreamRuntime(Api api, QObject *parent) + : QObject(parent) + , d(std::make_unique(api)) +{ +} + +NativeStreamRuntime::~NativeStreamRuntime() +{ + if (shutdown()) return; + std::shared_ptr callbacks; + { + const std::lock_guard lock(d->handleMutex); + d->handle = nullptr; + callbacks = std::exchange(d->callbackState, {}); + } + if (!callbacks) return; + { + const std::lock_guard lock(callbacks->mutex); + callbacks->accepting = false; + callbacks->target.clear(); + callbacks->pending.clear(); + callbacks->framePending = false; + } + new std::shared_ptr(std::move(callbacks)); +} + +bool NativeStreamRuntime::running() const +{ + const std::lock_guard lock(d->handleMutex); + return d->handle != nullptr; +} + +QString NativeStreamRuntime::lastError() const +{ + return d->lastError; +} + +bool NativeStreamRuntime::start() +{ + if (!d->api.create || !d->api.send || !d->api.destroy) { + setLastError(u"The embedded streamer FFI is incomplete."_s); + return false; + } + + { + const std::lock_guard lock(d->handleMutex); + if (d->handle) return true; + } + + auto callbacks = std::make_shared(); + callbacks->target = this; + OpenNowStreamerConfig config{}; + config.abi_version = OPENNOW_STREAMER_FFI_ABI_VERSION; + config.struct_size = sizeof(config); + config.command_queue_capacity = CommandQueueCapacity; + config.response_queue_capacity = ResponseQueueCapacity; + config.event_queue_capacity = EventQueueCapacity; + config.max_command_bytes = static_cast(MaximumCallbackBytes); + config.response_callback = &NativeStreamRuntime::responseCallback; + config.event_callback = &NativeStreamRuntime::eventCallback; + config.frame_available_callback = &NativeStreamRuntime::frameAvailableCallback; + config.cursor_callback = &NativeStreamRuntime::cursorCallback; + config.user_data = callbacks.get(); + + OpenNowStreamer *handle = nullptr; + const auto status = d->api.create(&config, &handle); + if (status != OPENNOW_STREAMER_OK || !handle) { + setLastError(status == OPENNOW_STREAMER_OK + ? u"The embedded streamer did not return a runtime handle."_s + : statusText(status)); + return false; + } + + { + const std::lock_guard lock(d->handleMutex); + d->callbackState = std::move(callbacks); + d->handle = handle; + } + setLastError({}); + emit runningChanged(); + return true; +} + +bool NativeStreamRuntime::send(const QJsonObject &command) +{ + return sendBytes(QJsonDocument(command).toJson(QJsonDocument::Compact)); +} + +bool NativeStreamRuntime::sendBytes(const QByteArray &command) +{ + if (command.size() > MaximumCallbackBytes) { + setLastError(statusText(OPENNOW_STREAMER_MESSAGE_TOO_LARGE)); + return false; + } + + OpenNowStreamerStatus status = OPENNOW_STREAMER_CLOSED; + { + const std::lock_guard lock(d->handleMutex); + if (d->handle) { + status = d->api.send( + d->handle, reinterpret_cast(command.constData()), + static_cast(command.size())); + } + } + if (status != OPENNOW_STREAMER_OK) { + setLastError(statusText(status)); + return false; + } + setLastError({}); + return true; +} + +bool NativeStreamRuntime::shutdown(int timeoutMs) +{ + OpenNowStreamer *handle = nullptr; + std::shared_ptr callbacks; + bool graphicsActive = false; + { + const std::lock_guard lock(d->handleMutex); + graphicsActive = d->handle && d->graphicsActive; + if (!graphicsActive) { + handle = std::exchange(d->handle, nullptr); + callbacks = std::exchange(d->callbackState, {}); + } + } + if (graphicsActive) { + setLastError(u"The embedded streamer scene graph must be released on its render " + "thread before shutdown."_s); + return false; + } + if (!handle) return true; + emit runningChanged(); + + if (callbacks) { + const std::lock_guard lock(callbacks->mutex); + callbacks->accepting = false; + callbacks->target.clear(); + callbacks->pending.clear(); + callbacks->dropped = 0; + callbacks->framePending = false; + } + + struct Completion { + std::mutex mutex; + std::condition_variable changed; + bool finished = false; + OpenNowStreamerStatus status = OPENNOW_STREAMER_CLOSED; + }; + auto completion = std::make_shared(); + std::thread destroyer([api = d->api, handle, callbacks = std::move(callbacks), completion] { + const auto status = api.destroy(handle); + { + const std::lock_guard lock(completion->mutex); + completion->status = status; + completion->finished = true; + } + completion->changed.notify_one(); + }); + + const auto boundedTimeout = std::clamp(timeoutMs, 0, 30'000); + std::unique_lock completionLock(completion->mutex); + const auto finished = completion->changed.wait_for( + completionLock, std::chrono::milliseconds(boundedTimeout), + [&completion] { return completion->finished; }); + if (!finished) { + completionLock.unlock(); + destroyer.detach(); + setLastError(u"Timed out waiting for the embedded streamer to stop; cleanup continues " + "off the Qt thread."_s); + return false; + } + const auto status = completion->status; + completionLock.unlock(); + destroyer.join(); + if (status != OPENNOW_STREAMER_OK) { + setLastError(statusText(status)); + return false; + } + setLastError({}); + return true; +} + +OpenNowStreamerStatus NativeStreamRuntime::setGraphicsContext( + const OpenNowStreamerGraphicsContext &context) +{ + const std::lock_guard lock(d->handleMutex); + if (!d->handle || !d->api.setGraphicsContext) return OPENNOW_STREAMER_CLOSED; + const auto status = d->api.setGraphicsContext(d->handle, &context); + if (status == OPENNOW_STREAMER_OK) d->graphicsActive = true; + return status; +} + +OpenNowStreamerStatus NativeStreamRuntime::recordLatestFrame( + const OpenNowStreamerRecordCommand &command, + OpenNowStreamerFrameInfo *info, + OpenNowStreamerRecordedFrame *recorded, + OpenNowStreamerFrame **frame) +{ + if (!info || !recorded || !frame) return OPENNOW_STREAMER_NULL_POINTER; + *info = {}; + *recorded = {}; + *frame = nullptr; + const std::lock_guard lock(d->handleMutex); + if (!d->handle || !d->api.acquireLatestFrame || !d->api.recordFrame + || !d->api.releaseFrame) { + return OPENNOW_STREAMER_CLOSED; + } + + auto status = d->api.acquireLatestFrame(d->handle, frame, info); + if (status != OPENNOW_STREAMER_OK) return status; + if (!*frame) return OPENNOW_STREAMER_NULL_POINTER; + status = d->api.recordFrame(d->handle, *frame, &command, recorded); + if (status != OPENNOW_STREAMER_OK) { + d->api.releaseFrame(std::exchange(*frame, nullptr)); + } + return status; +} + +OpenNowStreamerStatus NativeStreamRuntime::releaseFrame(OpenNowStreamerFrame *frame) +{ + if (!frame) return OPENNOW_STREAMER_NULL_POINTER; + return d->api.releaseFrame ? d->api.releaseFrame(frame) : OPENNOW_STREAMER_CLOSED; +} + +OpenNowStreamerStatus NativeStreamRuntime::sceneGraphShutdown() +{ + const std::lock_guard lock(d->handleMutex); + if (!d->handle || !d->api.sceneGraphShutdown) return OPENNOW_STREAMER_CLOSED; + const auto status = d->api.sceneGraphShutdown(d->handle); + if (status == OPENNOW_STREAMER_OK) d->graphicsActive = false; + return status; +} + +OpenNowStreamerStatus NativeStreamRuntime::submitKey(std::uint16_t virtualKey, + std::uint16_t modifiers, bool pressed) +{ + const std::lock_guard lock(d->handleMutex); + return d->handle && d->api.submitKey + ? d->api.submitKey(d->handle, virtualKey, modifiers, pressed) + : OPENNOW_STREAMER_CLOSED; +} + +OpenNowStreamerStatus NativeStreamRuntime::submitMouseRelative(std::int16_t deltaX, + std::int16_t deltaY) +{ + const std::lock_guard lock(d->handleMutex); + return d->handle && d->api.submitMouseRelative + ? d->api.submitMouseRelative(d->handle, deltaX, deltaY) + : OPENNOW_STREAMER_CLOSED; +} + +OpenNowStreamerStatus NativeStreamRuntime::submitMouseAbsolute( + std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height) +{ + const std::lock_guard lock(d->handleMutex); + return d->handle && d->api.submitMouseAbsolute + ? d->api.submitMouseAbsolute(d->handle, x, y, width, height) + : OPENNOW_STREAMER_CLOSED; +} + +OpenNowStreamerStatus NativeStreamRuntime::submitMouseButton(std::uint8_t button, + bool pressed) +{ + const std::lock_guard lock(d->handleMutex); + return d->handle && d->api.submitMouseButton + ? d->api.submitMouseButton(d->handle, button, pressed) + : OPENNOW_STREAMER_CLOSED; +} + +OpenNowStreamerStatus NativeStreamRuntime::submitMouseWheel(std::int16_t deltaX, + std::int16_t deltaY) +{ + const std::lock_guard lock(d->handleMutex); + return d->handle && d->api.submitMouseWheel + ? d->api.submitMouseWheel(d->handle, deltaX, deltaY) + : OPENNOW_STREAMER_CLOSED; +} + +OpenNowStreamerStatus NativeStreamRuntime::submitGamepad( + std::uint8_t controllerId, std::uint16_t bitmap, std::uint16_t buttons, + std::uint8_t leftTrigger, std::uint8_t rightTrigger, std::int16_t leftStickX, + std::int16_t leftStickY, std::int16_t rightStickX, std::int16_t rightStickY) +{ + const std::lock_guard lock(d->handleMutex); + return d->handle && d->api.submitGamepad + ? d->api.submitGamepad(d->handle, controllerId, bitmap, buttons, + leftTrigger, rightTrigger, leftStickX, leftStickY, + rightStickX, rightStickY) + : OPENNOW_STREAMER_CLOSED; +} + +OpenNowStreamerStatus NativeStreamRuntime::submitLocalAction(std::uint32_t action) +{ + const std::lock_guard lock(d->handleMutex); + return d->handle && d->api.submitLocalAction + ? d->api.submitLocalAction(d->handle, action) : OPENNOW_STREAMER_CLOSED; +} + +OpenNowStreamerStatus NativeStreamRuntime::setCaptureActive( + bool active, bool relativeMouse, std::uintptr_t windowHandle, bool *rawInputActive) +{ + if (!rawInputActive) return OPENNOW_STREAMER_NULL_POINTER; + *rawInputActive = false; + const std::lock_guard lock(d->handleMutex); + return d->handle && d->api.setCaptureActive + ? d->api.setCaptureActive(d->handle, active, relativeMouse, windowHandle, + rawInputActive) + : OPENNOW_STREAMER_CLOSED; +} + +void NativeStreamRuntime::responseCallback(const std::uint8_t *bytes, std::size_t length, + void *userData) +{ + enqueueCallback(static_cast(userData), bytes, length, false); +} + +void NativeStreamRuntime::eventCallback(const std::uint8_t *bytes, std::size_t length, + void *userData) +{ + enqueueCallback(static_cast(userData), bytes, length, true); +} + +void NativeStreamRuntime::frameAvailableCallback(void *userData) +{ + enqueueFrameAvailable(static_cast(userData)); +} + +void NativeStreamRuntime::cursorCallback(const std::uint8_t *bytes, std::size_t length, + void *userData) +{ + auto *state = static_cast(userData); + if (!state || (!bytes && length != 0) + || length > static_cast(MaximumCallbackBytes) + || length > static_cast(std::numeric_limits::max())) { + return; + } + const auto sharedState = state->shared_from_this(); + bool shouldSchedule = false; + { + const std::lock_guard lock(state->mutex); + if (!state->accepting) return; + if (state->pending.size() >= MaximumPendingCallbacks) { + ++state->dropped; + return; + } + state->pending.enqueue( + {QByteArray(reinterpret_cast(bytes), + static_cast(length)), false, true}); + if (!state->drainScheduled) { + state->drainScheduled = true; + shouldSchedule = true; + } + } + if (shouldSchedule) scheduleDrain(sharedState); +} + +void NativeStreamRuntime::enqueueCallback(CallbackState *state, const std::uint8_t *bytes, + std::size_t length, bool event) +{ + if (!state || (!bytes && length != 0) + || length > static_cast(MaximumCallbackBytes) + || length > static_cast(std::numeric_limits::max())) { + return; + } + + const auto sharedState = state->shared_from_this(); + bool shouldSchedule = false; + { + const std::lock_guard lock(state->mutex); + if (!state->accepting) return; + if (state->pending.size() >= MaximumPendingCallbacks) { + ++state->dropped; + return; + } + state->pending.enqueue( + {QByteArray(reinterpret_cast(bytes), static_cast(length)), + event}); + if (!state->drainScheduled) { + state->drainScheduled = true; + shouldSchedule = true; + } + } + if (shouldSchedule) scheduleDrain(sharedState); +} + +void NativeStreamRuntime::enqueueFrameAvailable(CallbackState *state) +{ + if (!state) return; + const auto sharedState = state->shared_from_this(); + bool shouldSchedule = false; + { + const std::lock_guard lock(state->mutex); + if (!state->accepting) return; + state->framePending = true; + if (!state->drainScheduled) { + state->drainScheduled = true; + shouldSchedule = true; + } + } + if (shouldSchedule) scheduleDrain(sharedState); +} + +void NativeStreamRuntime::scheduleDrain(const std::shared_ptr &state) +{ + auto *application = QCoreApplication::instance(); + if (application && QMetaObject::invokeMethod( + application, + [state] { + if (auto *target = state->target.data()) target->drainCallbacks(state); + }, + Qt::QueuedConnection)) { + return; + } + const std::lock_guard lock(state->mutex); + state->drainScheduled = false; + state->pending.clear(); +} + +void NativeStreamRuntime::drainCallbacks(const std::shared_ptr &state) +{ + QQueue messages; + int dropped = 0; + bool framePending = false; + bool reschedule = false; + { + const std::lock_guard lock(state->mutex); + constexpr qsizetype BatchSize = 64; + while (!state->pending.isEmpty() && messages.size() < BatchSize) + messages.enqueue(state->pending.dequeue()); + dropped = std::exchange(state->dropped, 0); + framePending = std::exchange(state->framePending, false); + reschedule = !state->pending.isEmpty() || state->framePending; + if (!reschedule) state->drainScheduled = false; + } + + if (dropped > 0) emit callbacksDropped(dropped); + if (framePending) emit frameAvailable(); + while (!messages.isEmpty()) { + const auto message = messages.dequeue(); + if (message.cursor) { + emit cursorUpdated(message.bytes); + continue; + } + QJsonParseError error; + const auto document = QJsonDocument::fromJson(message.bytes, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + setLastError(u"The embedded streamer callback contained malformed JSON."_s); + continue; + } + if (message.event) + emit eventReceived(document.object()); + else + emit responseReceived(document.object()); + } + if (reschedule) scheduleDrain(state); +} + +void NativeStreamRuntime::setLastError(const QString &error) +{ + if (d->lastError == error) return; + d->lastError = error; + emit lastErrorChanged(); +} diff --git a/opennow-qt/src/NativeStreamRuntime.h b/opennow-qt/src/NativeStreamRuntime.h new file mode 100644 index 000000000..b7b36f233 --- /dev/null +++ b/opennow-qt/src/NativeStreamRuntime.h @@ -0,0 +1,144 @@ +#pragma once + +#include "opennow_streamer_ffi.h" + +#include +#include +#include +#include + +#include +#include +#include + +class NativeStreamRuntime final : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool running READ running NOTIFY runningChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + +public: + struct Api { + using Create = OpenNowStreamerStatus (*)(const OpenNowStreamerConfig *, + OpenNowStreamer **); + using Send = OpenNowStreamerStatus (*)(const OpenNowStreamer *, + const std::uint8_t *, std::size_t); + using Destroy = OpenNowStreamerStatus (*)(OpenNowStreamer *); + using SetGraphicsContext = OpenNowStreamerStatus (*)( + const OpenNowStreamer *, const OpenNowStreamerGraphicsContext *); + using AcquireLatestFrame = OpenNowStreamerStatus (*)( + const OpenNowStreamer *, OpenNowStreamerFrame **, OpenNowStreamerFrameInfo *); + using RecordFrame = OpenNowStreamerStatus (*)( + const OpenNowStreamer *, const OpenNowStreamerFrame *, + const OpenNowStreamerRecordCommand *, OpenNowStreamerRecordedFrame *); + using ReleaseFrame = OpenNowStreamerStatus (*)(OpenNowStreamerFrame *); + using SceneGraphShutdown = OpenNowStreamerStatus (*)(const OpenNowStreamer *); + using SubmitKey = OpenNowStreamerStatus (*)(const OpenNowStreamer *, std::uint16_t, + std::uint16_t, bool); + using SubmitMouseRelative = OpenNowStreamerStatus (*)(const OpenNowStreamer *, + std::int16_t, std::int16_t); + using SubmitMouseAbsolute = OpenNowStreamerStatus (*)(const OpenNowStreamer *, + std::uint16_t, std::uint16_t, + std::uint16_t, std::uint16_t); + using SubmitMouseButton = OpenNowStreamerStatus (*)(const OpenNowStreamer *, + std::uint8_t, bool); + using SubmitMouseWheel = OpenNowStreamerStatus (*)(const OpenNowStreamer *, + std::int16_t, std::int16_t); + using SubmitGamepad = OpenNowStreamerStatus (*)( + const OpenNowStreamer *, std::uint8_t, std::uint16_t, std::uint16_t, + std::uint8_t, std::uint8_t, std::int16_t, std::int16_t, std::int16_t, + std::int16_t); + using SubmitLocalAction = OpenNowStreamerStatus (*)(const OpenNowStreamer *, + std::uint32_t); + using SetCaptureActive = OpenNowStreamerStatus (*)(const OpenNowStreamer *, bool, + bool, std::uintptr_t, bool *); + + Create create = nullptr; + Send send = nullptr; + Destroy destroy = nullptr; + SetGraphicsContext setGraphicsContext = nullptr; + AcquireLatestFrame acquireLatestFrame = nullptr; + RecordFrame recordFrame = nullptr; + ReleaseFrame releaseFrame = nullptr; + SceneGraphShutdown sceneGraphShutdown = nullptr; + SubmitKey submitKey = nullptr; + SubmitMouseRelative submitMouseRelative = nullptr; + SubmitMouseAbsolute submitMouseAbsolute = nullptr; + SubmitMouseButton submitMouseButton = nullptr; + SubmitMouseWheel submitMouseWheel = nullptr; + SubmitGamepad submitGamepad = nullptr; + SubmitLocalAction submitLocalAction = nullptr; + SetCaptureActive setCaptureActive = nullptr; + }; + + static constexpr int DefaultShutdownTimeoutMs = 1'500; + static constexpr qsizetype MaximumCallbackBytes = 1024 * 1024; + static constexpr qsizetype MaximumPendingCallbacks = 512; + + explicit NativeStreamRuntime(QObject *parent = nullptr); + explicit NativeStreamRuntime(Api api, QObject *parent = nullptr); + ~NativeStreamRuntime() override; + + [[nodiscard]] bool running() const; + [[nodiscard]] QString lastError() const; + + Q_INVOKABLE bool start(); + Q_INVOKABLE bool send(const QJsonObject &command); + bool sendBytes(const QByteArray &command); + Q_INVOKABLE bool shutdown(int timeoutMs = DefaultShutdownTimeoutMs); + + OpenNowStreamerStatus setGraphicsContext( + const OpenNowStreamerGraphicsContext &context); + OpenNowStreamerStatus recordLatestFrame( + const OpenNowStreamerRecordCommand &command, + OpenNowStreamerFrameInfo *info, + OpenNowStreamerRecordedFrame *recorded, + OpenNowStreamerFrame **frame); + OpenNowStreamerStatus releaseFrame(OpenNowStreamerFrame *frame); + OpenNowStreamerStatus sceneGraphShutdown(); + OpenNowStreamerStatus submitKey(std::uint16_t virtualKey, std::uint16_t modifiers, + bool pressed); + OpenNowStreamerStatus submitMouseRelative(std::int16_t deltaX, std::int16_t deltaY); + OpenNowStreamerStatus submitMouseAbsolute(std::uint16_t x, std::uint16_t y, + std::uint16_t width, std::uint16_t height); + OpenNowStreamerStatus submitMouseButton(std::uint8_t button, bool pressed); + OpenNowStreamerStatus submitMouseWheel(std::int16_t deltaX, std::int16_t deltaY); + OpenNowStreamerStatus submitGamepad(std::uint8_t controllerId, std::uint16_t bitmap, + std::uint16_t buttons, std::uint8_t leftTrigger, + std::uint8_t rightTrigger, std::int16_t leftStickX, + std::int16_t leftStickY, std::int16_t rightStickX, + std::int16_t rightStickY); + OpenNowStreamerStatus submitLocalAction(std::uint32_t action); + OpenNowStreamerStatus setCaptureActive(bool active, bool relativeMouse, + std::uintptr_t windowHandle, + bool *rawInputActive); + +signals: + void runningChanged(); + void lastErrorChanged(); + void responseReceived(const QJsonObject &response); + void eventReceived(const QJsonObject &event); + void frameAvailable(); + void cursorUpdated(const QByteArray &bytes); + void callbacksDropped(int count); + +private: + struct CallbackState; + struct Private; + + static void responseCallback(const std::uint8_t *bytes, std::size_t length, + void *userData); + static void eventCallback(const std::uint8_t *bytes, std::size_t length, + void *userData); + static void frameAvailableCallback(void *userData); + static void cursorCallback(const std::uint8_t *bytes, std::size_t length, + void *userData); + static void enqueueCallback(CallbackState *state, const std::uint8_t *bytes, + std::size_t length, bool event); + static void enqueueFrameAvailable(CallbackState *state); + static void scheduleDrain(const std::shared_ptr &state); + void drainCallbacks(const std::shared_ptr &state); + void setLastError(const QString &error); + + std::unique_ptr d; +}; diff --git a/opennow-qt/src/StreamSurfaceController.cpp b/opennow-qt/src/StreamSurfaceController.cpp deleted file mode 100644 index 3f4903ec9..000000000 --- a/opennow-qt/src/StreamSurfaceController.cpp +++ /dev/null @@ -1,435 +0,0 @@ -#include "StreamSurfaceController.h" - -#include "AppController.h" -#include "CoreClient.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -using namespace Qt::StringLiterals; - -namespace { -constexpr int UpdateDelayMs = 40; -constexpr int HostDiscoveryDelayMs = 50; -constexpr int MinimumSurfaceSize = 64; -constexpr int SurfaceHandoffTimeoutMs = 1'500; - -QJsonObject rectObject(int x, int y, int width, int height) -{ - return QJsonObject{{u"x"_s, x}, {u"y"_s, y}, - {u"width"_s, width}, {u"height"_s, height}}; -} -} - -StreamSurfaceController::StreamSurfaceController(CoreClient *coreClient, - AppController *appController, - QObject *parent) - : QObject(parent) - , m_coreClient(coreClient) - , m_appController(appController) - , m_capability(capabilityFor(currentPlatformFamily(), QGuiApplication::platformName())) -{ - Q_ASSERT(m_coreClient); - Q_ASSERT(m_appController); - - m_appController->setOverlayTransitionGuard( - [this](bool opening) { return prepareOverlayTransition(opening); }); - - m_updateTimer.setSingleShot(true); - m_updateTimer.setInterval(UpdateDelayMs); - connect(&m_updateTimer, &QTimer::timeout, this, &StreamSurfaceController::flushUpdate); - - m_hostTimer.setSingleShot(true); - m_hostTimer.setInterval(HostDiscoveryDelayMs); - connect(&m_hostTimer, &QTimer::timeout, this, &StreamSurfaceController::discoverHost); - - connect(m_appController, &AppController::routeChanged, this, [this] { - refreshSurface(true); - if (m_appController->route() == u"stream"_s) { - m_hostTimer.start(0); - } - }); - connect(m_appController, &AppController::overlayChanged, this, - [this] { refreshSurface(true); }); - connect(m_coreClient, &CoreClient::stateChanged, this, [this] { - if (m_coreClient->state() != u"ready"_s) { - m_requestId.clear(); - m_streamerActive = false; - return; - } - if (m_streamerActive || !m_surface.value(u"visible"_s).toBool()) { - scheduleUpdate(true); - } - }); - connect(m_coreClient, &CoreClient::responseReceived, - this, &StreamSurfaceController::processResponse); - connect(m_coreClient, &CoreClient::requestFailed, this, - [this](const QString &requestId, const QString &, const QString &message) { - processFailure(requestId, message); - }); - connect(m_coreClient, &CoreClient::eventReceived, - this, &StreamSurfaceController::processEvent); - - m_surface = encodeSurface(Metrics{}); -} - -StreamSurfaceController::~StreamSurfaceController() -{ - m_appController->setOverlayTransitionGuard({}); - if (m_window) m_window->removeEventFilter(this); - if (m_host) m_host->removeEventFilter(this); -} - -QJsonObject StreamSurfaceController::surface() const { return m_surface; } -QString StreamSurfaceController::compositionMode() const { return m_capability.mode; } -QString StreamSurfaceController::compositionDescription() const -{ - return m_capability.description; -} -bool StreamSurfaceController::embedded() const { return m_capability.embedded; } -bool StreamSurfaceController::hostAvailable() const { return !m_host.isNull(); } -QString StreamSurfaceController::lastError() const { return m_lastError; } - -void StreamSurfaceController::setWindow(QQuickWindow *window) -{ - if (m_window == window) return; - if (m_window) { - m_window->removeEventFilter(this); - disconnect(m_window, nullptr, this, nullptr); - } - setHost(nullptr); - m_window = window; - m_tearingDown = false; - if (!m_window) { - refreshSurface(true); - return; - } - - m_window->installEventFilter(this); - const auto refresh = [this] { refreshSurface(); }; - connect(m_window, &QQuickWindow::xChanged, this, refresh); - connect(m_window, &QQuickWindow::yChanged, this, refresh); - connect(m_window, &QQuickWindow::widthChanged, this, refresh); - connect(m_window, &QQuickWindow::heightChanged, this, refresh); - connect(m_window, &QQuickWindow::visibilityChanged, this, refresh); - connect(m_window, &QQuickWindow::screenChanged, this, refresh); - connect(m_window, &QObject::destroyed, this, [this] { - m_window = nullptr; - setHost(nullptr); - refreshSurface(true); - }); - discoverHost(); -} - -void StreamSurfaceController::teardown() -{ - if (m_tearingDown) return; - const auto streamerActive = m_streamerActive; - m_tearingDown = true; - refreshSurface(true); - if (streamerActive && m_coreClient->state() == u"ready"_s - && !waitForAppliedGeneration(m_generation)) { - setLastError(u"Timed out detaching the native presenter during Qt teardown."_s); - } - m_streamerActive = false; -} - -StreamSurfaceController::Capability StreamSurfaceController::capabilityFor( - PlatformFamily family, const QString &platformName) -{ - switch (family) { - case PlatformFamily::Windows: - return {u"embedded-child"_s, - u"Windows attaches the out-of-process native presenter as a child HWND inside " - "the Qt window. It is not a Qt Quick texture, so the shell hides it before " - "showing QML stream UI."_s, - true, true}; - case PlatformFamily::Linux: - if (platformName.compare(u"xcb"_s, Qt::CaseInsensitive) == 0) { - return {u"paired-auxiliary"_s, - u"The current X11 launch contract uses a separate native top-level surface " - "aligned to the Qt stream region; ordinary QML cannot overlap it reliably."_s, - false, true}; - } - if (platformName.contains(u"wayland"_s, Qt::CaseInsensitive)) { - return {u"paired-auxiliary"_s, - u"Wayland does not permit foreign child-window embedding; presentation uses " - "a separate compositor-managed surface."_s, - false, true}; - } - return {u"unsupported"_s, - u"Native stream-surface attachment is supported on Linux only through X11 or " - "the explicit Wayland auxiliary-surface fallback."_s, - false, false}; - case PlatformFamily::MacOS: - return {u"paired-auxiliary"_s, - u"macOS cannot embed an AppKit view across the streamer process boundary; " - "presentation uses an ordered auxiliary NSWindow."_s, - false, true}; - case PlatformFamily::Other: - return {u"unsupported"_s, - u"This operating system has no native stream-surface attachment contract."_s, - false, false}; - } - return {}; -} - -QJsonObject StreamSurfaceController::encodeSurface(const Metrics &metrics) -{ - const auto dpr = std::clamp(std::isfinite(metrics.devicePixelRatio) - ? metrics.devicePixelRatio : 1.0, - 0.5, 8.0); - const auto localX = qRound(metrics.windowRect.x() * dpr); - const auto localY = qRound(metrics.windowRect.y() * dpr); - const auto actualWidth = qRound(metrics.windowRect.width() * dpr); - const auto actualHeight = qRound(metrics.windowRect.height() * dpr); - const auto width = std::max(MinimumSurfaceSize, actualWidth); - const auto height = std::max(MinimumSurfaceSize, actualHeight); - const auto visible = metrics.visible && actualWidth >= MinimumSurfaceSize - && actualHeight >= MinimumSurfaceSize && !metrics.windowHandle.isEmpty(); - QJsonObject surface{ - {u"rect"_s, rectObject(localX, localY, width, height)}, - {u"screenRect"_s, - rectObject(qRound(metrics.screenPosition.x() * dpr), - qRound(metrics.screenPosition.y() * dpr), width, height)}, - {u"logicalScreenRect"_s, - rectObject(qRound(metrics.screenPosition.x()), qRound(metrics.screenPosition.y()), - std::max(MinimumSurfaceSize, qRound(metrics.windowRect.width())), - std::max(MinimumSurfaceSize, qRound(metrics.windowRect.height())))}, - {u"visible"_s, visible}, - {u"deviceScaleFactor"_s, dpr}, - }; - if (visible) surface.insert(u"windowHandle"_s, metrics.windowHandle); - return surface; -} - -bool StreamSurfaceController::eventFilter(QObject *watched, QEvent *event) -{ - if (watched == m_window && event->type() == QEvent::PlatformSurface) { - const auto *surfaceEvent = static_cast(event); - if (surfaceEvent->surfaceEventType() == QPlatformSurfaceEvent::SurfaceAboutToBeDestroyed) { - m_tearingDown = true; - refreshSurface(true); - if (m_streamerActive && m_coreClient->state() == u"ready"_s - && !waitForAppliedGeneration(m_generation)) { - setLastError(u"Timed out detaching the native presenter before Qt surface teardown."_s); - } - } else { - m_tearingDown = false; - refreshSurface(true); - } - } else if (watched == m_host && event->type() == QEvent::Destroy) { - setHost(nullptr); - refreshSurface(true); - if (m_appController->route() == u"stream"_s) m_hostTimer.start(); - } else if (watched == m_window - && (event->type() == QEvent::Show || event->type() == QEvent::Hide - || event->type() == QEvent::Move || event->type() == QEvent::Resize - || event->type() == QEvent::WindowStateChange - || event->type() == QEvent::DevicePixelRatioChange)) { - refreshSurface(); - } - return QObject::eventFilter(watched, event); -} - -StreamSurfaceController::PlatformFamily StreamSurfaceController::currentPlatformFamily() -{ -#if defined(Q_OS_WIN) - return PlatformFamily::Windows; -#elif defined(Q_OS_LINUX) - return PlatformFamily::Linux; -#elif defined(Q_OS_MACOS) - return PlatformFamily::MacOS; -#else - return PlatformFamily::Other; -#endif -} - -QString StreamSurfaceController::encodeWindowHandle(WId handle) -{ - return handle == 0 ? QString{} : u"0x%1"_s.arg(static_cast(handle), 0, 16); -} - -void StreamSurfaceController::discoverHost() -{ - if (!m_window || m_tearingDown) return; - auto *host = m_window->findChild(u"streamSurfaceHost"_s, - Qt::FindChildrenRecursively); - setHost(host); - refreshSurface(true); - if (!host && m_appController->route() == u"stream"_s) m_hostTimer.start(); -} - -void StreamSurfaceController::setHost(QQuickItem *host) -{ - if (m_host == host) return; - const auto wasAvailable = !m_host.isNull(); - if (m_host) { - m_host->removeEventFilter(this); - disconnect(m_host, nullptr, this, nullptr); - } - m_host = host; - if (m_host) { - m_host->installEventFilter(this); - const auto refresh = [this] { refreshSurface(); }; - connect(m_host, &QQuickItem::xChanged, this, refresh); - connect(m_host, &QQuickItem::yChanged, this, refresh); - connect(m_host, &QQuickItem::widthChanged, this, refresh); - connect(m_host, &QQuickItem::heightChanged, this, refresh); - connect(m_host, &QQuickItem::visibleChanged, this, refresh); - connect(m_host, &QQuickItem::windowChanged, this, [this](QQuickWindow *) { - refreshSurface(true); - }); - } - if (wasAvailable != !m_host.isNull()) emit hostAvailableChanged(); -} - -void StreamSurfaceController::refreshSurface(bool immediate) -{ - Metrics metrics; - if (m_window && m_host) { - const auto sceneRect = m_host->mapRectToScene(m_host->boundingRect()); - metrics.windowRect = sceneRect; - metrics.screenPosition = m_window->mapToGlobal(sceneRect.topLeft()); - metrics.devicePixelRatio = m_window->effectiveDevicePixelRatio(); - const auto windowUsable = m_window->isVisible() - && m_window->visibility() != QWindow::Hidden - && m_window->visibility() != QWindow::Minimized; - metrics.visible = !m_tearingDown && m_capability.presentationSupported - && m_appController->route() == u"stream"_s && m_appController->overlay().isEmpty() - && !m_forceHidden && windowUsable && m_host->isVisible(); - if (metrics.visible) metrics.windowHandle = encodeWindowHandle(m_window->winId()); - } - - const auto next = encodeSurface(metrics); - if (next == m_surface) return; - m_surface = next; - ++m_generation; - m_dirty = true; - m_failureCount = 0; - setLastError({}); - emit surfaceChanged(); - if (m_streamerActive || !m_surface.value(u"visible"_s).toBool()) { - scheduleUpdate(immediate); - } -} - -bool StreamSurfaceController::prepareOverlayTransition(bool opening) -{ - m_forceHidden = opening; - refreshSurface(true); - if (!opening || !m_streamerActive || m_coreClient->state() != u"ready"_s) { - return true; - } - if (waitForAppliedGeneration(m_generation)) { - return true; - } - setLastError(u"Native presenter did not release input and hide before the overlay opened."_s); - return false; -} - -bool StreamSurfaceController::waitForAppliedGeneration(quint64 generation) -{ - if (m_lastAppliedGeneration >= generation) return true; - - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - connect(this, &StreamSurfaceController::surfaceGenerationApplied, &loop, - [&loop, generation](quint64 appliedGeneration) { - if (appliedGeneration >= generation) loop.quit(); - }); - timeout.start(SurfaceHandoffTimeoutMs); - loop.exec(QEventLoop::ExcludeUserInputEvents); - return m_lastAppliedGeneration >= generation; -} - -void StreamSurfaceController::scheduleUpdate(bool immediate) -{ - m_dirty = true; - if (immediate) { - m_updateTimer.stop(); - flushUpdate(); - } else if (!m_updateTimer.isActive()) { - m_updateTimer.start(); - } -} - -void StreamSurfaceController::flushUpdate() -{ - if (!m_dirty || !m_requestId.isEmpty() || m_coreClient->state() != u"ready"_s) return; - m_requestGeneration = m_generation; - m_requestId = m_coreClient->request( - u"streamer.surface.update"_s, QJsonObject{{u"surface"_s, m_surface}}, 5'000); - if (m_requestId.isEmpty()) { - setLastError(u"Core transport is not ready for the stream-surface update."_s); - return; - } - m_dirty = false; -} - -void StreamSurfaceController::setLastError(const QString &error) -{ - if (m_lastError == error) return; - m_lastError = error; - emit lastErrorChanged(); -} - -void StreamSurfaceController::processResponse(const QString &requestId, - const QJsonObject &result) -{ - if (requestId != m_requestId) return; - m_requestId.clear(); - const auto currentChanged = m_requestGeneration != m_generation; - const auto applied = result.value(u"applied"_s).toBool(); - if (applied) { - m_failureCount = 0; - setLastError({}); - m_lastAppliedGeneration = std::max(m_lastAppliedGeneration, m_requestGeneration); - emit surfaceGenerationApplied(m_requestGeneration); - } else if (m_streamerActive) { - ++m_failureCount; - setLastError(u"Native streamer did not apply the current stream surface."_s); - } - if (currentChanged || m_dirty || (!applied && m_streamerActive && m_failureCount < 3)) { - scheduleUpdate(); - } -} - -void StreamSurfaceController::processFailure(const QString &requestId, const QString &message) -{ - if (requestId != m_requestId) return; - m_requestId.clear(); - ++m_failureCount; - m_dirty = true; - setLastError(message); - if (m_streamerActive && m_failureCount < 3) scheduleUpdate(); -} - -void StreamSurfaceController::processEvent(const QString &name, const QJsonObject &payload) -{ - if (name != u"streamer.changed"_s) return; - const auto nested = payload.value(u"streamer"_s).toObject(); - const auto status = nested.isEmpty() - ? payload.value(u"status"_s).toString() - : nested.value(u"status"_s).toString(); - const auto active = !status.isEmpty() && status != u"stopped"_s && status != u"error"_s; - if (active && !m_streamerActive) { - m_streamerActive = true; - m_failureCount = 0; - scheduleUpdate(true); - } else if (!active) { - m_streamerActive = false; - m_requestId.clear(); - } -} diff --git a/opennow-qt/src/StreamSurfaceController.h b/opennow-qt/src/StreamSurfaceController.h deleted file mode 100644 index 3d598f096..000000000 --- a/opennow-qt/src/StreamSurfaceController.h +++ /dev/null @@ -1,112 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -class AppController; -class CoreClient; -class QQuickItem; -class QQuickWindow; - -class StreamSurfaceController final : public QObject -{ - Q_OBJECT - Q_PROPERTY(QJsonObject surface READ surface NOTIFY surfaceChanged) - Q_PROPERTY(QString compositionMode READ compositionMode CONSTANT) - Q_PROPERTY(QString compositionDescription READ compositionDescription CONSTANT) - Q_PROPERTY(bool embedded READ embedded CONSTANT) - Q_PROPERTY(bool hostAvailable READ hostAvailable NOTIFY hostAvailableChanged) - Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) - -public: - enum class PlatformFamily { - Windows, - Linux, - MacOS, - Other, - }; - Q_ENUM(PlatformFamily) - - struct Capability { - QString mode; - QString description; - bool embedded = false; - bool presentationSupported = false; - - friend bool operator==(const Capability &, const Capability &) = default; - }; - - struct Metrics { - QRectF windowRect; - QPointF screenPosition; - qreal devicePixelRatio = 1.0; - QString windowHandle; - bool visible = false; - }; - - explicit StreamSurfaceController(CoreClient *coreClient, - AppController *appController, - QObject *parent = nullptr); - ~StreamSurfaceController() override; - - [[nodiscard]] QJsonObject surface() const; - [[nodiscard]] QString compositionMode() const; - [[nodiscard]] QString compositionDescription() const; - [[nodiscard]] bool embedded() const; - [[nodiscard]] bool hostAvailable() const; - [[nodiscard]] QString lastError() const; - - void setWindow(QQuickWindow *window); - Q_INVOKABLE void teardown(); - - [[nodiscard]] static Capability capabilityFor(PlatformFamily family, - const QString &platformName); - [[nodiscard]] static QJsonObject encodeSurface(const Metrics &metrics); - -signals: - void surfaceChanged(); - void hostAvailableChanged(); - void lastErrorChanged(); - void surfaceGenerationApplied(quint64 generation); - -protected: - bool eventFilter(QObject *watched, QEvent *event) override; - -private: - bool prepareOverlayTransition(bool opening); - bool waitForAppliedGeneration(quint64 generation); - static PlatformFamily currentPlatformFamily(); - static QString encodeWindowHandle(WId handle); - void discoverHost(); - void setHost(QQuickItem *host); - void refreshSurface(bool immediate = false); - void scheduleUpdate(bool immediate = false); - void flushUpdate(); - void setLastError(const QString &error); - void processResponse(const QString &requestId, const QJsonObject &result); - void processFailure(const QString &requestId, const QString &message); - void processEvent(const QString &name, const QJsonObject &payload); - - CoreClient *m_coreClient; - AppController *m_appController; - QPointer m_window; - QPointer m_host; - Capability m_capability; - QJsonObject m_surface; - QTimer m_updateTimer; - QTimer m_hostTimer; - QString m_requestId; - QString m_lastError; - quint64 m_generation = 0; - quint64 m_requestGeneration = 0; - quint64 m_lastAppliedGeneration = 0; - int m_failureCount = 0; - bool m_dirty = false; - bool m_streamerActive = false; - bool m_tearingDown = false; - bool m_forceHidden = false; -}; diff --git a/opennow-qt/src/StreamVideoItem.cpp b/opennow-qt/src/StreamVideoItem.cpp new file mode 100644 index 000000000..3599eaa71 --- /dev/null +++ b/opennow-qt/src/StreamVideoItem.cpp @@ -0,0 +1,800 @@ +#include "StreamVideoItem.h" + +#include "NativeStreamRuntime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { +QPointer nativeRuntime; + +QShader streamShader(const char *encoded) +{ + return QShader::fromSerialized(QByteArray::fromBase64(encoded)); +} + +class NativeStreamRenderCallback final : public StreamVideoRenderCallback +{ +public: + explicit NativeStreamRenderCallback(NativeStreamRuntime *runtime) + : m_runtime(runtime) + { + } + + void initialize(QRhi *rhi, QRhiCommandBuffer *, QRhiRenderTarget *renderTarget) override + { + if (m_rhi == rhi && m_renderTarget == renderTarget) return; + releaseResources(); + m_rhi = rhi; + m_renderTarget = renderTarget; + + OpenNowStreamerGraphicsContext context{}; + context.version = OPENNOW_STREAMER_GRAPHICS_CONTEXT_VERSION; + context.struct_size = sizeof(context); + switch (rhi->backend()) { +#if defined(Q_OS_WIN) + case QRhi::D3D11: { + const auto *handles = static_cast( + rhi->nativeHandles()); + if (!handles) return; + context.graphics_api = OPENNOW_STREAMER_GRAPHICS_API_D3D11; + context.device = handles->dev; + context.queue = handles->context; + break; + } +#endif +#if QT_CONFIG(vulkan) && __has_include() + case QRhi::Vulkan: { + const auto *handles = static_cast( + rhi->nativeHandles()); + if (!handles || !handles->inst) return; + context.graphics_api = OPENNOW_STREAMER_GRAPHICS_API_VULKAN; + context.instance = reinterpret_cast(handles->inst->vkInstance()); + context.physical_device = reinterpret_cast(handles->physDev); + context.device = reinterpret_cast(handles->dev); + context.queue = reinterpret_cast(handles->gfxQueue); + context.queue_family_index = handles->gfxQueueFamilyIdx; + break; + } +#endif +#if QT_CONFIG(metal) + case QRhi::Metal: { + const auto *handles = static_cast( + rhi->nativeHandles()); + if (!handles) return; + context.graphics_api = OPENNOW_STREAMER_GRAPHICS_API_METAL; + context.device = handles->dev; + context.queue = handles->cmdQueue; + break; + } +#endif + default: + return; + } + m_graphicsReady = m_runtime + && m_runtime->setGraphicsContext(context) == OPENNOW_STREAMER_OK; + } + + void prepareFrame(QRhiCommandBuffer *commandBuffer) override + { + if (!m_graphicsReady || !m_rhi || !commandBuffer) return; + + OpenNowStreamerRecordCommand command{}; + command.version = OPENNOW_STREAMER_RENDER_COMMAND_VERSION; + command.struct_size = sizeof(command); + command.frame_slot = static_cast(m_rhi->currentFrameSlot()); + finishFrame(); + switch (m_rhi->backend()) { +#if defined(Q_OS_WIN) + case QRhi::D3D11: { + const auto *handles = static_cast( + m_rhi->nativeHandles()); + command.command_buffer = handles ? handles->context : nullptr; + break; + } +#endif +#if QT_CONFIG(vulkan) && __has_include() + case QRhi::Vulkan: { + const auto *handles = static_cast( + commandBuffer->nativeHandles()); + command.command_buffer = handles + ? reinterpret_cast(handles->commandBuffer) : nullptr; + break; + } +#endif +#if QT_CONFIG(metal) + case QRhi::Metal: { + const auto *handles = static_cast( + commandBuffer->nativeHandles()); + command.command_buffer = handles ? handles->commandBuffer : nullptr; + break; + } +#endif + default: + return; + } + if (!command.command_buffer) return; + + OpenNowStreamerFrameInfo info{}; + OpenNowStreamerRecordedFrame recorded{}; + OpenNowStreamerFrame *frame = nullptr; + const auto status = m_runtime->recordLatestFrame(command, &info, &recorded, &frame); + if (status == OPENNOW_STREAMER_NO_FRAME) return; + if (status != OPENNOW_STREAMER_OK || !frame || recorded.resource == 0 + || recorded.width == 0 || recorded.height == 0) { + if (frame) m_runtime->releaseFrame(frame); + return; + } + m_preparedFrame = frame; + + if (m_texture && m_textureObject == recorded.resource + && m_textureSize == QSize(static_cast(recorded.width), + static_cast(recorded.height))) { + m_frameReady = true; + return; + } + + delete m_bindings; + m_bindings = nullptr; + delete m_texture; + m_texture = m_rhi->newTexture( + QRhiTexture::RGBA8, + QSize(static_cast(recorded.width), static_cast(recorded.height)), 1); + QRhiTexture::NativeTexture nativeTexture{}; + nativeTexture.object = recorded.resource; +#if QT_CONFIG(vulkan) && __has_include() + if (recorded.graphics_api == OPENNOW_STREAMER_GRAPHICS_API_VULKAN) + nativeTexture.layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; +#endif + if (!m_texture->createFrom(nativeTexture)) { + delete m_texture; + m_texture = nullptr; + return; + } + m_textureObject = recorded.resource; + m_textureSize = m_texture->pixelSize(); + + if (!m_sampler) { + m_sampler = m_rhi->newSampler(QRhiSampler::Linear, QRhiSampler::Linear, + QRhiSampler::None, + QRhiSampler::ClampToEdge, + QRhiSampler::ClampToEdge); + if (!m_sampler->create()) return; + } + m_bindings = m_rhi->newShaderResourceBindings(); + m_bindings->setBindings({QRhiShaderResourceBinding::sampledTexture( + 0, QRhiShaderResourceBinding::FragmentStage, m_texture, m_sampler)}); + if (!m_bindings->create()) return; + + if (!m_pipeline) { + static const auto vertexShader = streamShader( + "AAASBHic7VZtT9tWFDav6UwZ7SgtG3S7vLRzWAgmUFQRoK3aiSJVgxXGJkVR5MY3qaXEtvwSwVCk/Yr9nf2tfZm2c+49Tq6T0AKbtH3YlWzfc+5zXn39XGuaNq51xxBco1qsteA5jIo/aWg3G4lvu6safT0yvJTBULp2S9sg7V0Ih8EnwASf+2+O3+TDyM5vPjFx/VNtpJPgFNgJT3A1LcfFOYNrGa41uF5rY9qX5HeCnoluiHRDim6YdBhhH+6Jbh8iLNPaCsnoP/E9DSjELYDNCNnch9k45fMA8BlqAq7NgHyL1nB8Q/InFHseZB2eGdIxkCdI1gmPviaF9S+/5SD/jOiN1E0qmCmBef48wdwhHWIS3V2Sp4RuXMT4jPzdofXE3z0l53FRi8jhWeLrPmFmFMwDadLJYZZ0M0oOn5PdpJLDF+Rvltaxj3PUx1Glb/P0HrFvD2l9Xrwf+V4Tm4fUy6+oz7hWJJmRDt/NAslDFAPlRaob7ZdobYHsUV4mHa4/pneVITlHvudIzpOcIXuU10i3BTkPU30a6X4HzRhhcR/cVmxRniZ5D9C36d3hfJp6uEs1PKI8X4A3zPFr8vWIMOjDIP33gMGeZUmnKboV0mG9R2A1J/oksSvK+8c4WPsq9XOR8lolfJKX2ZPXOtVkKnkVSI/2ayT/ARk8FVloo/T9o3CmdcbQ7FKLB6HjuWy9YOp61XPDiLV4tcAq609LG2W2K6RS2cCHsbqeN7M5oTI28maOqQqc5xios9liylNhq9+TiVjFuNAjmx3v4ExvWcG549alv7iFGs+xGRZlZPULncGoNypHXuhEWIyItWlgDaA+5UHEzw5elXOsE7YobOIWQDE/FVXU27qu9GyUtgGN8eUlP7DqTYtVGxbkZDtW3fXCyKkyByYBt9ni6o9NJwwh41U/8CIvOvd5uKhfx+5dYFXRRl9y3GojtjnbafLIalSA6xvOuz1FHzpNew1v+fd7uh6jOXOtJg99cMGEFTQs4k2/YUV8B5PBZXaSY6HzM69E7Lu4uaeHURBXIxb6rR/c0KrxF0FgnVNvTxhv8CZ3o7AEWPYMLdg2Wy/LNopb9D7gls1OHjPP54EVeQErlZlBIXwvzBJEoKVfHAGP4sDtBgAkuW2Lu9hJ/MwP5OwqcVTgNaOJm81bDrTuQyEk5G+WcoU4KvAmpfSGFHfLja4QNIHeJKzsfT3wYv8KO0Lg/pFt8fGIfehrxG0XiSKxgekPZafW8KyoAPy3h9QJtHLpunHBpJDQKUkqoSoASamsnZDqR2IXtq4WWyFg0hT6NF0SFuETikBeNCteHBE7SDSSaakUhzwwGl7VNbNl6ptY3kzxcwnbKqYIwra2BPt2XcuZETtQbZecXRswpZIEVxy7XE7Yv2uI1y67aMvgIOXTJ4NMR5wN4NxIO8/2nxHooXNODLKQMNo3gE7OjzGREx26Nj17Dl3z/0N38KGLvfpV+VG51+3Zk/9wz3Dz/Sv9GqZ/vuS3eA6IAn8tZHn0hXZadcFEE2rZ/NmZyj81WUotzUA10awakEBRH+g26ZtKLjVRQi1FL306UwmJ3hP3/ZTRidxPCMWUFbWeqOr46ODtaeVl4IVh5cD1O5Q1iFe22fFpp8GSlQZ4OYwjfwDzbbOTb396eXj49pV5KeuJAN2C2skOQTqrfGCbKIyVTlnulaSrl+4WARUbpr8QuT372sSgpXVecXCuJKW2axdfhaHg8j3EKPNRipOKATlIJ54QJEjV9PB3alP0YUX5uAMUTk67b/d+Nj3/92L0fE5y/AUa/w92"); + static const auto fragmentShader = streamShader( + "AAAJEHicxVXdahNBFB6Ttmk31rYqahFktDcphBhDK9I0uWlUCoVKE6oQwrJuJtuF/Qm7s6FSeucz+BA+gE/jg3gjes7MbHY2idY7l0xm5pvzP+ecIYSsEEKWYNwSc0ImMBdgkF/qI/kP6Qxik5B4MCLF/SfqWc5HZAQ8FvEJIz0Yl4SDzghWhFRu4F/0rSi5lxr2eWfCotgNA/qiUTeMJHBHYeTT2PLHHosaHTqKLJ/12CVPItY0jIkVfXIDh06Y3aDJBJHQHVLfcoPKrnFlUPgcz3wTWU7H4la/PqAtyiV7o1PRpVWBf7dpXBsGxgUlqPjc1sLwdccNbC8ZMnroM255ZsyHnvuxbWR47PrD5/hXu2iDBzGaF4CaeGzZjAouMDPmUWJzYWjdDBOubB15ocX3qB16YUT7fTFX6ruDARg2y+UGOhO6DxxJzKKKF9pBxgROOj4LNGVyVUnFUPj1+zG3HAa7waA6jdDwUMhu58IOtOpcWFZNbydH1AUIPVBngjK9kMwOHC16BVYiDrua9LyVk1WTQipz8qtgeU3cGrJHDA4ClJLe4rLQpO5uqF3j92Jhp4TZZ5BVcl+hW1A7mI9lyEyc3550T2pwv7W9fSHjDilOa2GDlERu4FCpQtaEDEI2gQ7xEsqAFeJPlA7cr8P8bGb/VNtvafT3wKaCOC8K7bh+ACusHQrjIdCXSNYJKPyvwlxU+ptqv6awbVgZWu3pX7p/DJRlQgQdykvzv6zk4X5dYah/Q8krKPrNaYwk/aaKC2IvybKgW1L8aNMPQJZhbgFaFnGW8ltK9l3F/x6oSiImkgbxNkhYU9hP0PBKepGWL6pqZB4Wtntp6cvE3pvJ7ANIIseNOSQsr0NadWX2drnFGTV1UjNNeo0lRhaoUYu7dq6SmzlQNSpVy913x2fn5lEUxrF5HIzznUAU9QHtvf5wdHp61qnnuoDOeZrw8eImckC752bPihzGFbvokNgUzFybXFh5MgCVha5nDXPeEtmB53yjaZOBdaoXPGzpeA2jI9zITJTAAjWSLxQbSaQj026ibkHrEnnGa73nYwZ90epCe5P2/+VNwqYG79H07qG7/u15Sm1Me+qNbxLaN9Tq9VtmX71ujCNmu2J34ToXY5kMzTnYDQCc+iKx//bKph1Be2nnutNMKc+dk99YTBT/"); + if (!vertexShader.isValid() || !fragmentShader.isValid()) return; + m_pipeline = m_rhi->newGraphicsPipeline(); + m_pipeline->setTopology(QRhiGraphicsPipeline::Triangles); + m_pipeline->setShaderStages({ + {QRhiShaderStage::Vertex, vertexShader}, + {QRhiShaderStage::Fragment, fragmentShader}, + }); + m_pipeline->setShaderResourceBindings(m_bindings); + m_pipeline->setRenderPassDescriptor(m_renderTarget->renderPassDescriptor()); + if (!m_pipeline->create()) { + delete m_pipeline; + m_pipeline = nullptr; + return; + } + } + m_frameReady = true; + } + + void recordFrame(QRhiCommandBuffer *commandBuffer, const QRect &) override + { + if (!m_frameReady || !m_pipeline || !m_bindings) return; + commandBuffer->setGraphicsPipeline(m_pipeline); + commandBuffer->setShaderResources(m_bindings); + commandBuffer->draw(3); + } + + void finishFrame() override + { + if (m_preparedFrame && m_runtime) + m_runtime->releaseFrame(std::exchange(m_preparedFrame, nullptr)); + } + + void releaseResources() override + { + finishFrame(); + m_frameReady = false; + delete m_pipeline; + m_pipeline = nullptr; + delete m_bindings; + m_bindings = nullptr; + delete m_sampler; + m_sampler = nullptr; + delete m_texture; + m_texture = nullptr; + m_textureObject = 0; + m_textureSize = {}; + if (m_graphicsReady && m_runtime) + m_runtime->sceneGraphShutdown(); + m_graphicsReady = false; + m_rhi = nullptr; + m_renderTarget = nullptr; + } + +private: + NativeStreamRuntime *m_runtime; + QRhi *m_rhi = nullptr; + QRhiRenderTarget *m_renderTarget = nullptr; + QRhiTexture *m_texture = nullptr; + QRhiSampler *m_sampler = nullptr; + QRhiShaderResourceBindings *m_bindings = nullptr; + QRhiGraphicsPipeline *m_pipeline = nullptr; + QSize m_textureSize; + std::uint64_t m_textureObject = 0; + OpenNowStreamerFrame *m_preparedFrame = nullptr; + bool m_graphicsReady = false; + bool m_frameReady = false; +}; + +class StreamVideoItemRenderer final : public QQuickRhiItemRenderer +{ +public: + ~StreamVideoItemRenderer() override + { + releaseCallbackResources(); + } + +protected: + void initialize(QRhiCommandBuffer *commandBuffer) override + { + initializeCallback(commandBuffer); + } + + void synchronize(QQuickRhiItem *item) override + { + const auto *videoItem = static_cast(item); + const auto callback = videoItem->renderCallback(); + if (callback != m_callback) { + releaseCallbackResources(); + m_callback = callback; + } + m_videoSize = videoItem->videoSize(); + } + + void render(QRhiCommandBuffer *commandBuffer) override + { + initializeCallback(commandBuffer); + + if (m_callback) m_callback->prepareFrame(commandBuffer); + + auto *target = renderTarget(); + if (!target) { + if (m_callback) m_callback->finishFrame(); + return; + } + + commandBuffer->beginPass(target, QColor(Qt::black), {1.0f, 0}); + if (m_callback) { + const auto viewport = StreamVideoItem::aspectFitRect(m_videoSize, + target->pixelSize()); + commandBuffer->setViewport(QRhiViewport(viewport.x(), viewport.y(), + viewport.width(), viewport.height())); + m_callback->recordFrame(commandBuffer, viewport); + } + commandBuffer->endPass(); + if (m_callback) m_callback->finishFrame(); + } + +private: + void initializeCallback(QRhiCommandBuffer *commandBuffer) + { + if (!m_callback) return; + auto *currentRhi = rhi(); + auto *currentTarget = renderTarget(); + if (!currentRhi || !currentTarget) return; + if (m_initializedCallback == m_callback && m_initializedRhi == currentRhi + && m_initializedTarget == currentTarget) { + return; + } + releaseCallbackResources(); + m_callback->initialize(currentRhi, commandBuffer, currentTarget); + m_initializedCallback = m_callback; + m_initializedRhi = currentRhi; + m_initializedTarget = currentTarget; + } + + void releaseCallbackResources() + { + if (m_initializedCallback) m_initializedCallback->releaseResources(); + m_initializedCallback.reset(); + m_initializedRhi = nullptr; + m_initializedTarget = nullptr; + } + + QSize m_videoSize; + std::shared_ptr m_callback; + std::shared_ptr m_initializedCallback; + QRhi *m_initializedRhi = nullptr; + QRhiRenderTarget *m_initializedTarget = nullptr; +}; +} + +StreamVideoItem::StreamVideoItem(QQuickItem *parent) + : QQuickRhiItem(parent) +{ + setAlphaBlending(false); + setSampleCount(1); + setActiveFocusOnTab(true); + setAcceptedMouseButtons(Qt::AllButtons); + setAcceptHoverEvents(true); + if (nativeRuntime) { + setRenderCallback(std::make_shared(nativeRuntime)); + connect(nativeRuntime, &NativeStreamRuntime::frameAvailable, + this, &StreamVideoItem::requestFrame, Qt::QueuedConnection); + connect(nativeRuntime, &NativeStreamRuntime::cursorUpdated, + this, &StreamVideoItem::applyRemoteCursor, Qt::QueuedConnection); + } + connect(this, &QQuickItem::windowChanged, this, [this](QQuickWindow *currentWindow) { + if (currentWindow) + connect(currentWindow, &QWindow::activeChanged, + this, &StreamVideoItem::syncCaptureState, Qt::UniqueConnection); + syncCaptureState(); + }); +} + +QSize StreamVideoItem::videoSize() const +{ + return m_videoSize; +} + +void StreamVideoItem::setVideoSize(const QSize &size) +{ + const auto normalized = size.isValid() && !size.isEmpty() ? size : QSize{}; + if (m_videoSize == normalized) return; + m_videoSize = normalized; + emit videoSizeChanged(); + update(); +} + +bool StreamVideoItem::renderCallbackAvailable() const +{ + return static_cast(m_renderCallback); +} + +bool StreamVideoItem::nativeRuntimeAvailable() const +{ + return nativeRuntime && nativeRuntime->running(); +} + +bool StreamVideoItem::inputEnabled() const +{ + return m_inputEnabled; +} + +void StreamVideoItem::setInputEnabled(bool enabled) +{ + if (m_inputEnabled == enabled) return; + m_inputEnabled = enabled; + syncCaptureState(); + emit inputEnabledChanged(); +} + +bool StreamVideoItem::captureActive() const +{ + return m_captureActive; +} + +bool StreamVideoItem::relativeMouse() const +{ + return m_relativeMouse; +} + +std::shared_ptr StreamVideoItem::renderCallback() const +{ + return m_renderCallback; +} + +void StreamVideoItem::setRenderCallback(std::shared_ptr callback) +{ + if (m_renderCallback == callback) return; + const auto wasAvailable = renderCallbackAvailable(); + m_renderCallback = std::move(callback); + if (wasAvailable != renderCallbackAvailable()) emit renderCallbackAvailableChanged(); + update(); +} + +void StreamVideoItem::setNativeStreamRuntime(NativeStreamRuntime *runtime) +{ + nativeRuntime = runtime; +} + +NativeStreamRuntime *StreamVideoItem::nativeStreamRuntime() +{ + return nativeRuntime.data(); +} + +void StreamVideoItem::requestFrame() +{ + if (QThread::currentThread() != thread()) { + QMetaObject::invokeMethod(this, &StreamVideoItem::requestFrame, Qt::QueuedConnection); + return; + } + update(); +} + +QRect StreamVideoItem::aspectFitRect(const QSize &videoSize, const QSize &targetSize) +{ + if (!targetSize.isValid() || targetSize.isEmpty()) return {}; + if (!videoSize.isValid() || videoSize.isEmpty()) return QRect(QPoint{}, targetSize); + + const auto scale = std::min(static_cast(targetSize.width()) / videoSize.width(), + static_cast(targetSize.height()) / videoSize.height()); + const auto width = std::max(1, static_cast(std::floor(videoSize.width() * scale))); + const auto height = std::max(1, static_cast(std::floor(videoSize.height() * scale))); + return QRect((targetSize.width() - width) / 2, + (targetSize.height() - height) / 2, + width, height); +} + +quint16 StreamVideoItem::windowsVirtualKey(int key) +{ + if (key >= Qt::Key_A && key <= Qt::Key_Z) return static_cast(key); + if (key >= Qt::Key_0 && key <= Qt::Key_9) return static_cast(key); + if (key >= Qt::Key_F1 && key <= Qt::Key_F24) + return static_cast(0x70 + key - Qt::Key_F1); + switch (key) { + case Qt::Key_Return: + case Qt::Key_Enter: return 0x0d; + case Qt::Key_Escape: return 0x1b; + case Qt::Key_Backspace: return 0x08; + case Qt::Key_Tab: return 0x09; + case Qt::Key_Space: return 0x20; + case Qt::Key_Minus: return 0xbd; + case Qt::Key_Equal: return 0xbb; + case Qt::Key_BracketLeft: return 0xdb; + case Qt::Key_BracketRight: return 0xdd; + case Qt::Key_Backslash: return 0xdc; + case Qt::Key_Semicolon: return 0xba; + case Qt::Key_Apostrophe: return 0xde; + case Qt::Key_QuoteLeft: return 0xc0; + case Qt::Key_Comma: return 0xbc; + case Qt::Key_Period: return 0xbe; + case Qt::Key_Slash: return 0xbf; + case Qt::Key_Right: return 0x27; + case Qt::Key_Left: return 0x25; + case Qt::Key_Down: return 0x28; + case Qt::Key_Up: return 0x26; + case Qt::Key_Control: return 0xa2; + case Qt::Key_Shift: return 0xa0; + case Qt::Key_Alt: return 0xa4; + case Qt::Key_Meta: return 0x5b; + case Qt::Key_CapsLock: return 0x14; + case Qt::Key_NumLock: return 0x90; + case Qt::Key_Insert: return 0x2d; + case Qt::Key_Delete: return 0x2e; + case Qt::Key_Home: return 0x24; + case Qt::Key_End: return 0x23; + case Qt::Key_PageUp: return 0x21; + case Qt::Key_PageDown: return 0x22; + case Qt::Key_Print: return 0x2a; + case Qt::Key_ScrollLock: return 0x91; + case Qt::Key_Pause: return 0x13; + case Qt::Key_Menu: return 0x5d; + case Qt::Key_Plus: return 0x6b; + case Qt::Key_Asterisk: return 0x6a; + default: return 0; + } +} + +quint16 StreamVideoItem::inputModifiers(Qt::KeyboardModifiers modifiers, int key) +{ + quint16 result = 0; + if (key != Qt::Key_Shift && modifiers.testFlag(Qt::ShiftModifier)) result |= 0x01; + if (key != Qt::Key_Control && modifiers.testFlag(Qt::ControlModifier)) result |= 0x02; + if (key != Qt::Key_Alt && modifiers.testFlag(Qt::AltModifier)) result |= 0x04; + if (key != Qt::Key_Meta && modifiers.testFlag(Qt::MetaModifier)) result |= 0x08; + return result; +} + +void StreamVideoItem::focusInEvent(QFocusEvent *event) +{ + QQuickRhiItem::focusInEvent(event); + syncCaptureState(); +} + +void StreamVideoItem::focusOutEvent(QFocusEvent *event) +{ + releaseInput(); + syncCaptureState(); + QQuickRhiItem::focusOutEvent(event); +} + +quint32 StreamVideoItem::keyIdentity(const QKeyEvent *event) const +{ + return event->nativeScanCode() != 0 ? event->nativeScanCode() + : static_cast(event->key()); +} + +void StreamVideoItem::keyPressEvent(QKeyEvent *event) +{ + if (!m_captureActive || event->isAutoRepeat()) { + event->ignore(); + return; + } + const auto identity = keyIdentity(event); + if (event->key() == Qt::Key_G + && inputModifiers(event->modifiers(), event->key()) == 0x02) { + if (!m_pressedShortcuts.contains(identity)) { + m_pressedShortcuts.insert(identity); + nativeRuntime->submitLocalAction(OPENNOW_STREAMER_LOCAL_ACTION_GUIDE); + } + event->accept(); + return; + } + const auto virtualKey = windowsVirtualKey(event->key()); + if (virtualKey == 0) { + event->ignore(); + return; + } + if (!m_pressedKeys.contains(identity)) { + const auto modifiers = inputModifiers(event->modifiers(), event->key()); + m_pressedKeys.insert(identity, {virtualKey, modifiers}); + nativeRuntime->submitKey(virtualKey, modifiers, true); + } + event->accept(); +} + +void StreamVideoItem::keyReleaseEvent(QKeyEvent *event) +{ + if (event->isAutoRepeat()) { + event->accept(); + return; + } + const auto identity = keyIdentity(event); + if (m_pressedShortcuts.remove(identity)) { + event->accept(); + return; + } + const auto pressed = m_pressedKeys.take(identity); + if (pressed.virtualKey == 0) { + event->ignore(); + return; + } + nativeRuntime->submitKey(pressed.virtualKey, + inputModifiers(event->modifiers(), event->key()), false); + event->accept(); +} + +quint8 StreamVideoItem::mouseButton(Qt::MouseButton button) +{ + switch (button) { + case Qt::LeftButton: return 1; + case Qt::MiddleButton: return 2; + case Qt::RightButton: return 3; + case Qt::BackButton: return 4; + case Qt::ForwardButton: return 5; + default: return 0; + } +} + +void StreamVideoItem::mousePressEvent(QMouseEvent *event) +{ + forceActiveFocus(Qt::MouseFocusReason); + syncCaptureState(); + const auto button = mouseButton(event->button()); + if (m_captureActive && button != 0) { + if (!m_rawInputActive && !m_pressedMouseButtons.contains(button)) { + m_pressedMouseButtons.insert(button); + nativeRuntime->submitMouseButton(button, true); + } + m_lastMousePosition = event->position(); + event->accept(); + return; + } + event->ignore(); +} + +void StreamVideoItem::mouseReleaseEvent(QMouseEvent *event) +{ + const auto button = mouseButton(event->button()); + if (m_captureActive && button != 0) { + if (!m_rawInputActive && m_pressedMouseButtons.remove(button)) + nativeRuntime->submitMouseButton(button, false); + event->accept(); + return; + } + event->ignore(); +} + +void StreamVideoItem::mouseMoveEvent(QMouseEvent *event) +{ + if (!m_captureActive) { + event->ignore(); + return; + } + if (m_relativeMouse) { + if (!m_rawInputActive) { + const auto delta = event->position() - m_lastMousePosition; + const auto deltaX = std::clamp(qRound(delta.x()), -32768, 32767); + const auto deltaY = std::clamp(qRound(delta.y()), -32768, 32767); + if (deltaX != 0 || deltaY != 0) + nativeRuntime->submitMouseRelative(static_cast(deltaX), + static_cast(deltaY)); + const auto anchor = mapToGlobal(QPointF(width() / 2.0, height() / 2.0)).toPoint(); + QCursor::setPos(anchor); + m_lastMousePosition = mapFromGlobal(QCursor::pos()); + } + } else { + submitAbsoluteMouse(event->position()); + m_lastMousePosition = event->position(); + } + event->accept(); +} + +void StreamVideoItem::wheelEvent(QWheelEvent *event) +{ + if (!m_captureActive) { + event->ignore(); + return; + } + if (!m_rawInputActive) { + const auto delta = event->pixelDelta().isNull() ? event->angleDelta() + : event->pixelDelta(); + nativeRuntime->submitMouseWheel( + static_cast(std::clamp(delta.x(), -32768, 32767)), + static_cast(std::clamp(delta.y(), -32768, 32767))); + } + event->accept(); +} + +void StreamVideoItem::itemChange(ItemChange change, const ItemChangeData &data) +{ + QQuickRhiItem::itemChange(change, data); + if (change == ItemVisibleHasChanged || change == ItemSceneChange) + syncCaptureState(); +} + +void StreamVideoItem::submitAbsoluteMouse(const QPointF &position) +{ + const auto target = QSize(std::max(1, qRound(width())), std::max(1, qRound(height()))); + const auto viewport = aspectFitRect(m_videoSize, target); + const auto x = std::clamp(qRound(position.x()) - viewport.x(), 0, + std::max(0, viewport.width() - 1)); + const auto y = std::clamp(qRound(position.y()) - viewport.y(), 0, + std::max(0, viewport.height() - 1)); + nativeRuntime->submitMouseAbsolute( + static_cast(std::min(x, 65535)), + static_cast(std::min(y, 65535)), + static_cast(std::min(viewport.width(), 65535)), + static_cast(std::min(viewport.height(), 65535))); +} + +void StreamVideoItem::syncCaptureState() +{ + const auto desired = m_inputEnabled && isVisible() && hasActiveFocus() + && window() && window()->isActive() && nativeRuntime && nativeRuntime->running(); + if (!desired && m_captureActive) releaseInput(); + bool rawInput = false; + if (nativeRuntime && nativeRuntime->running()) { + nativeRuntime->setCaptureActive( + desired, m_relativeMouse, + window() ? static_cast(window()->winId()) : 0, + &rawInput); + } + m_rawInputActive = desired && rawInput; + if (m_captureActive == desired) return; + m_captureActive = desired; + if (m_captureActive) m_lastMousePosition = mapFromGlobal(QCursor::pos()); + emit captureActiveChanged(); +} + +void StreamVideoItem::releaseInput() +{ + if (!nativeRuntime) return; + for (const auto &pressed : std::as_const(m_pressedKeys)) + nativeRuntime->submitKey(pressed.virtualKey, 0, false); + m_pressedKeys.clear(); + m_pressedShortcuts.clear(); + if (!m_rawInputActive) { + for (const auto button : std::as_const(m_pressedMouseButtons)) + nativeRuntime->submitMouseButton(button, false); + } + m_pressedMouseButtons.clear(); + ungrabMouse(); +} + +void StreamVideoItem::setRelativeMouse(bool relative) +{ + if (m_relativeMouse == relative) return; + m_relativeMouse = relative; + if (relative) { + setCursor(Qt::BlankCursor); + if (m_captureActive) { + grabMouse(); + const auto anchor = mapToGlobal(QPointF(width() / 2.0, height() / 2.0)).toPoint(); + QCursor::setPos(anchor); + m_lastMousePosition = mapFromGlobal(QCursor::pos()); + } + } else { + ungrabMouse(); + unsetCursor(); + } + syncCaptureState(); + emit relativeMouseChanged(); +} + +void StreamVideoItem::applyRemoteCursor(const QByteArray &bytes) +{ + if (bytes.size() < 2) return; + const auto messageType = static_cast(bytes[0]); + const auto cursorId = static_cast(bytes[1]); + if (messageType > 1) return; + const auto hidden = messageType == 0 && cursorId == 0; + setRelativeMouse(hidden); + if (hidden) return; + + if (messageType == 1 && bytes.size() >= 7) { + const auto mimeLength = static_cast(bytes[4]); + const auto lengthOffset = 5 + mimeLength; + if (lengthOffset + 2 <= bytes.size()) { + const auto imageLength = static_cast( + static_cast(bytes[lengthOffset]) + | (static_cast(static_cast(bytes[lengthOffset + 1])) << 8)); + const auto imageStart = lengthOffset + 2; + if (imageStart + imageLength <= bytes.size()) { + QPixmap pixmap; + const auto image = QByteArray::fromBase64(bytes.mid(imageStart, imageLength)); + if (pixmap.loadFromData(image)) { + setCursor(QCursor(pixmap, + static_cast(bytes[2]), + static_cast(bytes[3]))); + return; + } + } + } + } + switch (cursorId) { + case 2: setCursor(Qt::IBeamCursor); break; + case 3: setCursor(Qt::WaitCursor); break; + case 4: setCursor(Qt::CrossCursor); break; + case 6: setCursor(Qt::SizeFDiagCursor); break; + case 7: setCursor(Qt::SizeBDiagCursor); break; + case 8: setCursor(Qt::SizeHorCursor); break; + case 9: setCursor(Qt::SizeVerCursor); break; + case 10: setCursor(Qt::SizeAllCursor); break; + case 12: setCursor(Qt::PointingHandCursor); break; + default: setCursor(Qt::ArrowCursor); break; + } +} + +QQuickRhiItemRenderer *StreamVideoItem::createRenderer() +{ + return new StreamVideoItemRenderer; +} + +void registerStreamVideoItemQmlType() +{ + qmlRegisterType("OpenNOW", 1, 0, "StreamVideoItem"); +} diff --git a/opennow-qt/src/StreamVideoItem.h b/opennow-qt/src/StreamVideoItem.h new file mode 100644 index 000000000..c746aac64 --- /dev/null +++ b/opennow-qt/src/StreamVideoItem.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +class QRhi; +class QRhiCommandBuffer; +class QRhiRenderTarget; +class NativeStreamRuntime; + +class StreamVideoRenderCallback +{ +public: + virtual ~StreamVideoRenderCallback() = default; + + virtual void initialize(QRhi *rhi, + QRhiCommandBuffer *commandBuffer, + QRhiRenderTarget *renderTarget) = 0; + virtual void prepareFrame(QRhiCommandBuffer *commandBuffer) = 0; + virtual void recordFrame(QRhiCommandBuffer *commandBuffer, + const QRect &videoViewport) = 0; + virtual void finishFrame() = 0; + virtual void releaseResources() = 0; +}; + +class StreamVideoItem : public QQuickRhiItem +{ + Q_OBJECT + Q_PROPERTY(QSize videoSize READ videoSize WRITE setVideoSize NOTIFY videoSizeChanged) + Q_PROPERTY(bool renderCallbackAvailable READ renderCallbackAvailable + NOTIFY renderCallbackAvailableChanged) + Q_PROPERTY(bool nativeRuntimeAvailable READ nativeRuntimeAvailable CONSTANT) + Q_PROPERTY(bool inputEnabled READ inputEnabled WRITE setInputEnabled + NOTIFY inputEnabledChanged) + Q_PROPERTY(bool captureActive READ captureActive NOTIFY captureActiveChanged) + Q_PROPERTY(bool relativeMouse READ relativeMouse WRITE setRelativeMouse + NOTIFY relativeMouseChanged) + +public: + explicit StreamVideoItem(QQuickItem *parent = nullptr); + + [[nodiscard]] QSize videoSize() const; + void setVideoSize(const QSize &size); + + [[nodiscard]] bool renderCallbackAvailable() const; + [[nodiscard]] bool nativeRuntimeAvailable() const; + [[nodiscard]] bool inputEnabled() const; + void setInputEnabled(bool enabled); + [[nodiscard]] bool captureActive() const; + [[nodiscard]] bool relativeMouse() const; + void setRelativeMouse(bool relative); + [[nodiscard]] std::shared_ptr renderCallback() const; + void setRenderCallback(std::shared_ptr callback); + + static void setNativeStreamRuntime(NativeStreamRuntime *runtime); + [[nodiscard]] static NativeStreamRuntime *nativeStreamRuntime(); + + Q_INVOKABLE void requestFrame(); + + [[nodiscard]] static QRect aspectFitRect(const QSize &videoSize, + const QSize &targetSize); + [[nodiscard]] static quint16 windowsVirtualKey(int key); + [[nodiscard]] static quint16 inputModifiers(Qt::KeyboardModifiers modifiers, int key); + +signals: + void videoSizeChanged(); + void renderCallbackAvailableChanged(); + void inputEnabledChanged(); + void captureActiveChanged(); + void relativeMouseChanged(); + +protected: + QQuickRhiItemRenderer *createRenderer() override; + void focusInEvent(QFocusEvent *event) override; + void focusOutEvent(QFocusEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void keyReleaseEvent(QKeyEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void itemChange(ItemChange change, const ItemChangeData &data) override; + +private: + struct PressedKey { + quint16 virtualKey = 0; + quint16 modifiers = 0; + }; + + void applyRemoteCursor(const QByteArray &bytes); + void syncCaptureState(); + void releaseInput(); + void submitAbsoluteMouse(const QPointF &position); + [[nodiscard]] quint32 keyIdentity(const QKeyEvent *event) const; + [[nodiscard]] static quint8 mouseButton(Qt::MouseButton button); + + QSize m_videoSize; + std::shared_ptr m_renderCallback; + QHash m_pressedKeys; + QSet m_pressedShortcuts; + QSet m_pressedMouseButtons; + QPointF m_lastMousePosition; + bool m_inputEnabled = true; + bool m_captureActive = false; + bool m_relativeMouse = false; + bool m_rawInputActive = false; +}; + +void registerStreamVideoItemQmlType(); diff --git a/opennow-qt/src/main.cpp b/opennow-qt/src/main.cpp index d37644af2..98dea26e7 100644 --- a/opennow-qt/src/main.cpp +++ b/opennow-qt/src/main.cpp @@ -4,8 +4,11 @@ #include "CoreClient.h" #include "InputModeTracker.h" #include "Localization.h" +#ifdef OPENNOW_EMBEDDED_STREAMER +#include "NativeStreamRuntime.h" +#endif #include "SingleInstance.h" -#include "StreamSurfaceController.h" +#include "StreamVideoItem.h" #include "ThumbnailGenerator.h" #include @@ -23,6 +26,7 @@ #include #include #include +#include #include #include @@ -69,9 +73,17 @@ int main(int argc, char *argv[]) QGuiApplication::setApplicationVersion(QString::fromLatin1(OPENNOW_VERSION)); QQuickWindow::setDefaultAlphaBuffer(true); QQuickWindow::setTextRenderType(QQuickWindow::QtTextRendering); +#if defined(Q_OS_WIN) + QQuickWindow::setGraphicsApi(QSGRendererInterface::Direct3D11); +#elif defined(Q_OS_MACOS) + QQuickWindow::setGraphicsApi(QSGRendererInterface::Metal); +#elif defined(Q_OS_LINUX) + QQuickWindow::setGraphicsApi(QSGRendererInterface::Vulkan); +#endif QQuickStyle::setStyle(u"Basic"_s); QGuiApplication application(argc, argv); + registerStreamVideoItemQmlType(); qSetMessagePattern(u"%{time yyyy-MM-ddTHH:mm:ss.zzz} %{type} %{category}: %{message}"_s); const QStringList bundledFonts = { u":/qt/qml/OpenNOW/res/fonts/Nunito-Variable.ttf"_s, @@ -103,7 +115,27 @@ int main(int argc, char *argv[]) Localization localization; application.installTranslator(&localization); CoreClient coreClient; - StreamSurfaceController streamSurfaceController(&coreClient, &controller); +#ifdef OPENNOW_EMBEDDED_STREAMER + NativeStreamRuntime nativeStreamRuntime; + if (!nativeStreamRuntime.start()) + qWarning("Could not start the embedded streamer runtime: %s", + qUtf8Printable(nativeStreamRuntime.lastError())); + StreamVideoItem::setNativeStreamRuntime(&nativeStreamRuntime); + QObject::connect( + &controllerInput, &ControllerInput::gamepadSnapshot, &nativeStreamRuntime, + [&nativeStreamRuntime](quint8 controllerId, quint16 bitmap, quint16 buttons, + quint8 leftTrigger, quint8 rightTrigger, + qint16 leftStickX, qint16 leftStickY, + qint16 rightStickX, qint16 rightStickY) { + nativeStreamRuntime.submitGamepad( + controllerId, bitmap, buttons, leftTrigger, rightTrigger, + leftStickX, leftStickY, rightStickX, rightStickY); + }); + QObject::connect(&controllerInput, &ControllerInput::localActionRequested, + &nativeStreamRuntime, [&nativeStreamRuntime](quint32 action) { + nativeStreamRuntime.submitLocalAction(action); + }); +#endif InputModeTracker inputModeTracker(&controller); application.installEventFilter(&inputModeTracker); controller.setControllerCount(controllerInput.controllerCount()); @@ -148,8 +180,10 @@ int main(int argc, char *argv[]) engine.rootContext()->setContextProperty(u"ThumbnailGenerator"_s, &thumbnailGenerator); engine.rootContext()->setContextProperty(u"I18n"_s, &localization); engine.rootContext()->setContextProperty(u"CoreClient"_s, &coreClient); - engine.rootContext()->setContextProperty(u"StreamSurfaceController"_s, - &streamSurfaceController); +#ifdef OPENNOW_EMBEDDED_STREAMER + engine.rootContext()->setContextProperty(u"NativeStreamRuntime"_s, + &nativeStreamRuntime); +#endif engine.rootContext()->setContextProperty( u"LaunchModeOverride"_s, arguments.contains(u"--desktop"_s) ? u"desktop"_s @@ -175,11 +209,6 @@ int main(int argc, char *argv[]) &application, [] { QCoreApplication::exit(EXIT_FAILURE); }, Qt::QueuedConnection); engine.loadFromModule(u"OpenNOW"_s, u"Main"_s); - auto *rootWindow = engine.rootObjects().isEmpty() - ? nullptr : qobject_cast(engine.rootObjects().first()); - streamSurfaceController.setWindow(rootWindow); - QObject::connect(&application, &QCoreApplication::aboutToQuit, - &streamSurfaceController, &StreamSurfaceController::teardown); const auto qmlReadyMs = startupTimer.elapsed(); controller.handleArguments(arguments); QObject::connect(&controller, &AppController::activationRequested, @@ -457,5 +486,15 @@ int main(int argc, char *argv[]) } } - return application.exec(); + const auto exitCode = application.exec(); +#ifdef OPENNOW_EMBEDDED_STREAMER + const auto roots = engine.rootObjects(); + for (auto *root : roots) delete root; + StreamVideoItem::setNativeStreamRuntime(nullptr); + if (!nativeStreamRuntime.shutdown()) { + qWarning("Could not complete embedded streamer shutdown: %s", + qUtf8Printable(nativeStreamRuntime.lastError())); + } +#endif + return exitCode; } diff --git a/opennow-qt/tests/fake_core.cpp b/opennow-qt/tests/fake_core.cpp index 11f8e4ad2..5841aa88d 100644 --- a/opennow-qt/tests/fake_core.cpp +++ b/opennow-qt/tests/fake_core.cpp @@ -62,12 +62,6 @@ int main(int argc, char **argv) } else if (method == "catalog.public.list") { std::cout << "{\"type\":\"response\",\"id\":\"" << id << "\",\"ok\":true,\"result\":{\"games\":[],\"totalCount\":0}}\n" << std::flush; - } else if (method == "streamer.detect") { - std::cout << "{\"type\":\"response\",\"id\":\"" << id - << "\",\"ok\":true,\"result\":{\"available\":false,\"availableCodecs\":[],\"capabilities\":{}}}\n" << std::flush; - } else if (method == "streamer.status.get") { - std::cout << "{\"type\":\"response\",\"id\":\"" << id - << "\",\"ok\":true,\"result\":{\"status\":\"stopped\",\"message\":\"Fixture streamer is stopped\",\"queueDropCount\":0}}\n" << std::flush; } else if (method == "test.streamer-event") { std::cout << "{\"type\":\"event\",\"name\":\"streamer.changed\",\"payload\":{\"status\":\"streaming\",\"sessionId\":\"fixture-session\",\"firstFrameLatencyMs\":37,\"mediaBackend\":\"ffmpeg\",\"deviceRecoveryCount\":2,\"queueDropCount\":4}}\n"; std::cout << "{\"type\":\"response\",\"id\":\"" << id @@ -76,9 +70,6 @@ int main(int argc, char **argv) std::cout << "{\"type\":\"event\",\"name\":\"streamer.changed\",\"payload\":{\"streamer\":{\"status\":\"streaming\",\"sessionId\":\"fixture-session\"}}}\n"; std::cout << "{\"type\":\"response\",\"id\":\"" << id << "\",\"ok\":true,\"result\":{}}\n" << std::flush; - } else if (method == "streamer.surface.update") { - std::cout << "{\"type\":\"response\",\"id\":\"" << id - << "\",\"ok\":true,\"result\":{\"applied\":true}}\n" << std::flush; } else if (method == "test.echo") { std::cout << "{\"type\":\"response\",\"id\":\"" << id << "\",\"ok\":true,\"result\":{\"value\":\"pong\"}}\n" << std::flush; diff --git a/opennow-qt/tests/tst_controllerinput.cpp b/opennow-qt/tests/tst_controllerinput.cpp index b19540a2c..a5891a6b7 100644 --- a/opennow-qt/tests/tst_controllerinput.cpp +++ b/opennow-qt/tests/tst_controllerinput.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include class ControllerKeySink final : public QObject @@ -78,6 +79,82 @@ private slots: QTRY_COMPARE_WITH_TIMEOUT(input.controllerCount(), initialCount, 2'000); QCoreApplication::instance()->removeEventFilter(&sink); } + + void publishesFullSnapshotsKeepaliveAndNeutralOwnershipTransition() + { + ControllerInput input; + QSignalSpy snapshots(&input, &ControllerInput::gamepadSnapshot); + + SDL_VirtualJoystickDesc descriptor{}; + SDL_INIT_INTERFACE(&descriptor); + descriptor.type = SDL_JOYSTICK_TYPE_GAMEPAD; + descriptor.naxes = SDL_GAMEPAD_AXIS_COUNT; + descriptor.nbuttons = SDL_GAMEPAD_BUTTON_COUNT; + descriptor.axis_mask = (1u << SDL_GAMEPAD_AXIS_LEFTX) + | (1u << SDL_GAMEPAD_AXIS_LEFT_TRIGGER); + descriptor.button_mask = 1u << SDL_GAMEPAD_BUTTON_SOUTH; + descriptor.name = "OpenNOW snapshot controller"; + const auto id = SDL_AttachVirtualJoystick(&descriptor); + QVERIFY2(id != 0, SDL_GetError()); + QTRY_COMPARE_WITH_TIMEOUT(input.controllerCount(), 1, 2'000); + + input.setShellCaptureEnabled(false); + QTRY_VERIFY_WITH_TIMEOUT(!snapshots.isEmpty(), 1'000); + QCOMPARE(snapshots.last().at(0).toUInt(), 0u); + QCOMPARE(snapshots.last().at(1).toUInt(), 0x0101u); + + auto *joystick = SDL_GetJoystickFromID(id); + QVERIFY(joystick); + QVERIFY(SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_SOUTH, true)); + QVERIFY(SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFTX, 24'000)); + QVERIFY(SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 32'767)); + SDL_UpdateJoysticks(); + QTRY_VERIFY_WITH_TIMEOUT(snapshots.last().at(2).toUInt() == 0x1000u, 1'000); + QVERIFY(snapshots.last().at(5).toInt() > 0); + + const auto beforeKeepalive = snapshots.size(); + QTRY_VERIFY_WITH_TIMEOUT(snapshots.size() > beforeKeepalive, 500); + + input.setShellCaptureEnabled(true); + QCOMPARE(snapshots.last().at(0).toUInt(), 0u); + QCOMPARE(snapshots.last().at(2).toUInt(), 0u); + QCOMPARE(snapshots.last().at(3).toUInt(), 0u); + QCOMPARE(snapshots.last().at(5).toInt(), 0); + + QVERIFY(SDL_DetachVirtualJoystick(id)); + QTRY_COMPARE_WITH_TIMEOUT(input.controllerCount(), 0, 2'000); + } + + void preservesExistingSlotsAcrossHotplug() + { + ControllerInput input; + SDL_VirtualJoystickDesc descriptor{}; + SDL_INIT_INTERFACE(&descriptor); + descriptor.type = SDL_JOYSTICK_TYPE_GAMEPAD; + descriptor.naxes = SDL_GAMEPAD_AXIS_COUNT; + descriptor.nbuttons = SDL_GAMEPAD_BUTTON_COUNT; + descriptor.name = "OpenNOW slot controller"; + const auto first = SDL_AttachVirtualJoystick(&descriptor); + const auto second = SDL_AttachVirtualJoystick(&descriptor); + QVERIFY(first != 0); + QVERIFY(second != 0); + QTRY_COMPARE_WITH_TIMEOUT(input.controllerCount(), 2, 2'000); + QCOMPARE(input.controllers().at(0).toMap().value(QStringLiteral("slot")).toInt(), 1); + QCOMPARE(input.controllers().at(1).toMap().value(QStringLiteral("slot")).toInt(), 2); + + QVERIFY(SDL_DetachVirtualJoystick(first)); + QTRY_COMPARE_WITH_TIMEOUT(input.controllerCount(), 1, 2'000); + QCOMPARE(input.controllers().at(0).toMap().value(QStringLiteral("slot")).toInt(), 2); + const auto replacement = SDL_AttachVirtualJoystick(&descriptor); + QVERIFY(replacement != 0); + QTRY_COMPARE_WITH_TIMEOUT(input.controllerCount(), 2, 2'000); + QCOMPARE(input.controllers().at(0).toMap().value(QStringLiteral("slot")).toInt(), 1); + QCOMPARE(input.controllers().at(1).toMap().value(QStringLiteral("slot")).toInt(), 2); + + QVERIFY(SDL_DetachVirtualJoystick(second)); + QVERIFY(SDL_DetachVirtualJoystick(replacement)); + QTRY_COMPARE_WITH_TIMEOUT(input.controllerCount(), 0, 2'000); + } }; QTEST_MAIN(ControllerInputTest) diff --git a/opennow-qt/tests/tst_embeddedorchestration.cpp b/opennow-qt/tests/tst_embeddedorchestration.cpp new file mode 100644 index 000000000..51334e2b7 --- /dev/null +++ b/opennow-qt/tests/tst_embeddedorchestration.cpp @@ -0,0 +1,85 @@ +#include +#include +#include + +namespace { +QString source(const QString &relativePath) +{ + QFile file(QStringLiteral(OPENNOW_QT_SOURCE_DIR) + u'/' + relativePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return {}; + return QString::fromUtf8(file.readAll()); +} +} + +class EmbeddedOrchestrationTest final : public QObject +{ + Q_OBJECT + +private slots: + void shellUsesCoreOnlyToPrepareEmbeddedContext() + { + const auto shell = source(QStringLiteral("qml/state/ShellStore.qml")); + QVERIFY(!shell.isEmpty()); + QVERIFY(shell.contains(QStringLiteral("CoreClient.request(\"streamer.prepare\""))); + + const QStringList forbiddenRoutes{ + QStringLiteral("CoreClient.request(\"streamer.start\""), + QStringLiteral("CoreClient.request(\"streamer.status.get\""), + QStringLiteral("CoreClient.request(\"streamer.stop\""), + QStringLiteral("CoreClient.request(\"streamer.input.pause\""), + QStringLiteral("CoreClient.request(\"streamer.control\""), + QStringLiteral("CoreClient.request(\"streamer.recording.start\""), + QStringLiteral("CoreClient.request(\"streamer.recording.stop\""), + QStringLiteral("CoreClient.request(\"streamer.surface.update\""), + QStringLiteral("CoreClient.request(\"streamer.detect\""), + }; + for (const auto &route : forbiddenRoutes) + QVERIFY2(!shell.contains(route), qPrintable(route)); + + const QStringList nativeCommands{ + QStringLiteral("sendNativeCommand(\"hello\""), + QStringLiteral("sendNativeCommand(\"start\""), + QStringLiteral("sendNativeCommand(\"stop\""), + QStringLiteral("sendNativeCommand(\"input-paused\""), + QStringLiteral("sendNativeCommand(\"recording-start\""), + QStringLiteral("sendNativeCommand(\"recording-stop\""), + }; + for (const auto &command : nativeCommands) + QVERIFY2(shell.contains(command), qPrintable(command)); + QVERIFY(shell.contains(QStringLiteral("\"toggle-fullscreen\": \"fullscreen-toggle\""))); + QVERIFY(shell.contains(QStringLiteral("target: NativeStreamRuntime"))); + } + + void applicationExposesRuntimeWithoutLegacySurfaceController() + { + const auto main = source(QStringLiteral("src/main.cpp")); + QVERIFY(!main.isEmpty()); + QVERIFY(main.contains(QStringLiteral("setContextProperty(u\"NativeStreamRuntime\"_s"))); + QVERIFY(main.contains(QStringLiteral("StreamVideoItem::setNativeStreamRuntime"))); + QVERIFY(!main.contains(QStringLiteral("StreamSurfaceController"))); + } + + void liveScreensRenderThroughStreamVideoItem() + { + const QStringList screens{ + QStringLiteral("qml/screens/StreamScreen.qml"), + QStringLiteral("qml/desktop/DesktopStreamScreen.qml"), + }; + const QRegularExpression liveItem( + QStringLiteral("StreamVideoItem\\s*\\{[^}]*objectName:\\s*\"streamSurfaceHost\""), + QRegularExpression::DotMatchesEverythingOption); + for (const auto &path : screens) { + const auto qml = source(path); + QVERIFY2(!qml.isEmpty(), qPrintable(path)); + QVERIFY2(liveItem.match(qml).hasMatch(), qPrintable(path)); + QVERIFY(qml.contains(QStringLiteral("visible: root.visible && root.streaming"))); + QVERIFY(qml.contains(QStringLiteral( + "inputEnabled: visible && AppController.overlay === \"\""))); + QVERIFY(!qml.contains(QStringLiteral( + "visible: root.visible && root.streaming && AppController.overlay === \"\""))); + } + } +}; + +QTEST_MAIN(EmbeddedOrchestrationTest) +#include "tst_embeddedorchestration.moc" diff --git a/opennow-qt/tests/tst_nativestreamruntime.cpp b/opennow-qt/tests/tst_nativestreamruntime.cpp new file mode 100644 index 000000000..9fbe34986 --- /dev/null +++ b/opennow-qt/tests/tst_nativestreamruntime.cpp @@ -0,0 +1,134 @@ +#include "NativeStreamRuntime.h" + +#include +#include +#include +#include + +#include +#include + +namespace { +struct FakeRuntime { + OpenNowStreamerConfig config{}; + int destroyDelayMs = 0; +}; + +int nextDestroyDelayMs = 0; + +OpenNowStreamerStatus fakeCreate(const OpenNowStreamerConfig *config, + OpenNowStreamer **output) +{ + if (!config || !output) return OPENNOW_STREAMER_NULL_POINTER; + auto *runtime = new FakeRuntime{*config, nextDestroyDelayMs}; + *output = reinterpret_cast(runtime); + return OPENNOW_STREAMER_OK; +} + +OpenNowStreamerStatus fakeSend(const OpenNowStreamer *handle, const std::uint8_t *bytes, + std::size_t length) +{ + if (!handle || (!bytes && length)) return OPENNOW_STREAMER_NULL_POINTER; + const auto *runtime = reinterpret_cast(handle); + const QByteArray command(reinterpret_cast(bytes), + static_cast(length)); + std::thread callback([runtime, command] { + const auto object = QJsonDocument::fromJson(command).object(); + const auto response = QJsonDocument(QJsonObject{{QStringLiteral("id"), + object.value(QStringLiteral("id"))}, + {QStringLiteral("type"), + QStringLiteral("ok")}}) + .toJson(QJsonDocument::Compact); + runtime->config.response_callback( + reinterpret_cast(response.constData()), + static_cast(response.size()), runtime->config.user_data); + if (runtime->config.frame_available_callback) + runtime->config.frame_available_callback(runtime->config.user_data); + if (runtime->config.cursor_callback) { + const std::uint8_t cursor[] = {0, 12, 0, 0, 0, 0, 0}; + runtime->config.cursor_callback(cursor, sizeof(cursor), runtime->config.user_data); + } + }); + callback.join(); + return OPENNOW_STREAMER_OK; +} + +OpenNowStreamerStatus fakeDestroy(OpenNowStreamer *handle) +{ + if (!handle) return OPENNOW_STREAMER_NULL_POINTER; + auto *runtime = reinterpret_cast(handle); + std::this_thread::sleep_for(std::chrono::milliseconds(runtime->destroyDelayMs)); + delete runtime; + return OPENNOW_STREAMER_OK; +} + +NativeStreamRuntime::Api fakeApi() +{ + return {&fakeCreate, &fakeSend, &fakeDestroy}; +} +} + +class NativeStreamRuntimeTest final : public QObject +{ + Q_OBJECT + +private slots: + void init() + { + nextDestroyDelayMs = 0; + } + + void copiesAndMarshalsWorkerCallbacksToTheQtThread() + { + NativeStreamRuntime runtime(fakeApi()); + QSignalSpy responses(&runtime, &NativeStreamRuntime::responseReceived); + QSignalSpy frames(&runtime, &NativeStreamRuntime::frameAvailable); + QSignalSpy cursors(&runtime, &NativeStreamRuntime::cursorUpdated); + QVERIFY(runtime.start()); + QVERIFY(runtime.send(QJsonObject{{QStringLiteral("id"), QStringLiteral("hello")}, + {QStringLiteral("type"), QStringLiteral("hello")}})); + + QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 1'000); + QCOMPARE(responses.first().first().toJsonObject().value(QStringLiteral("id")).toString(), + QStringLiteral("hello")); + QCOMPARE(responses.first().first().toJsonObject().value(QStringLiteral("type")).toString(), + QStringLiteral("ok")); + QTRY_COMPARE_WITH_TIMEOUT(frames.size(), 1, 1'000); + QTRY_COMPARE_WITH_TIMEOUT(cursors.size(), 1, 1'000); + QCOMPARE(cursors.first().first().toByteArray(), QByteArray::fromHex("000c0000000000")); + QVERIFY(runtime.shutdown()); + } + + void boundsShutdownWhenTheFfiDestroyCallStalls() + { + nextDestroyDelayMs = 300; + NativeStreamRuntime runtime(fakeApi()); + QVERIFY(runtime.start()); + + QElapsedTimer timer; + timer.start(); + QVERIFY(!runtime.shutdown(20)); + QVERIFY2(timer.elapsed() < 200, "shutdown blocked past its bounded deadline"); + QVERIFY(!runtime.running()); + QVERIFY(runtime.lastError().contains(QStringLiteral("Timed out"))); + QTest::qWait(350); + } + + void roundTripsThroughTheLinkedRustFfi() + { + NativeStreamRuntime runtime; + QSignalSpy responses(&runtime, &NativeStreamRuntime::responseReceived); + QVERIFY2(runtime.start(), qPrintable(runtime.lastError())); + QVERIFY2(runtime.send(QJsonObject{{QStringLiteral("id"), QStringLiteral("abi-hello")}, + {QStringLiteral("type"), QStringLiteral("hello")}, + {QStringLiteral("protocolVersion"), 5}}), + qPrintable(runtime.lastError())); + QTRY_VERIFY_WITH_TIMEOUT(!responses.isEmpty(), 5'000); + QCOMPARE(responses.first().first().toJsonObject().value(QStringLiteral("id")).toString(), + QStringLiteral("abi-hello")); + QVERIFY2(runtime.shutdown(), qPrintable(runtime.lastError())); + } +}; + +QTEST_MAIN(NativeStreamRuntimeTest) +#include "tst_nativestreamruntime.moc" diff --git a/opennow-qt/tests/tst_streamsurfacecontroller.cpp b/opennow-qt/tests/tst_streamsurfacecontroller.cpp deleted file mode 100644 index 4caa6fb04..000000000 --- a/opennow-qt/tests/tst_streamsurfacecontroller.cpp +++ /dev/null @@ -1,196 +0,0 @@ -#include "StreamSurfaceController.h" - -#include "AppController.h" -#include "CoreClient.h" - -#include -#include -#include -#include -#include -#include -#include - -using Family = StreamSurfaceController::PlatformFamily; - -class StreamSurfaceControllerTest final : public QObject -{ - Q_OBJECT - -private slots: - void reportsHonestCompositionModes() - { - const auto windows = StreamSurfaceController::capabilityFor(Family::Windows, {}); - QCOMPARE(windows.mode, QStringLiteral("embedded-child")); - QVERIFY(windows.presentationSupported); - QVERIFY(windows.embedded); - - const auto x11 = StreamSurfaceController::capabilityFor(Family::Linux, - QStringLiteral("xcb")); - QCOMPARE(x11.mode, QStringLiteral("paired-auxiliary")); - QVERIFY(x11.presentationSupported); - QVERIFY(!x11.embedded); - - const auto wayland = StreamSurfaceController::capabilityFor( - Family::Linux, QStringLiteral("wayland")); - QCOMPARE(wayland.mode, QStringLiteral("paired-auxiliary")); - QVERIFY(wayland.presentationSupported); - QVERIFY(!wayland.embedded); - - const auto mac = StreamSurfaceController::capabilityFor(Family::MacOS, {}); - QCOMPARE(mac.mode, QStringLiteral("paired-auxiliary")); - QVERIFY(mac.presentationSupported); - QVERIFY(!mac.embedded); - - const auto unsupported = StreamSurfaceController::capabilityFor(Family::Other, {}); - QCOMPARE(unsupported.mode, QStringLiteral("unsupported")); - QVERIFY(!unsupported.presentationSupported); - } - - void encodesPhysicalLocalAndScreenRects() - { - const auto surface = StreamSurfaceController::encodeSurface({ - .windowRect = QRectF(10.0, 20.0, 640.0, 360.0), - .screenPosition = QPointF(-90.0, 45.0), - .devicePixelRatio = 1.5, - .windowHandle = QStringLiteral("0x1234"), - .visible = true, - }); - QCOMPARE(surface.value(QStringLiteral("rect")).toObject(), - QJsonObject({{QStringLiteral("x"), 15}, {QStringLiteral("y"), 30}, - {QStringLiteral("width"), 960}, - {QStringLiteral("height"), 540}})); - QCOMPARE(surface.value(QStringLiteral("screenRect")).toObject(), - QJsonObject({{QStringLiteral("x"), -135}, {QStringLiteral("y"), 68}, - {QStringLiteral("width"), 960}, - {QStringLiteral("height"), 540}})); - QCOMPARE(surface.value(QStringLiteral("logicalScreenRect")).toObject(), - QJsonObject({{QStringLiteral("x"), -90}, {QStringLiteral("y"), 45}, - {QStringLiteral("width"), 640}, - {QStringLiteral("height"), 360}})); - QCOMPARE(surface.value(QStringLiteral("deviceScaleFactor")).toDouble(), 1.5); - QCOMPARE(surface.value(QStringLiteral("windowHandle")).toString(), - QStringLiteral("0x1234")); - QVERIFY(surface.value(QStringLiteral("visible")).toBool()); - } - - void hiddenSurfaceDropsPotentiallyStaleHandle() - { - const auto surface = StreamSurfaceController::encodeSurface({ - .windowRect = QRectF(0, 0, 1920, 1080), - .screenPosition = QPointF(100, 200), - .devicePixelRatio = 2.0, - .windowHandle = QStringLiteral("0xdeadbeef"), - .visible = false, - }); - QVERIFY(!surface.value(QStringLiteral("visible")).toBool()); - QVERIFY(!surface.contains(QStringLiteral("windowHandle"))); - QCOMPARE(surface.value(QStringLiteral("rect")).toObject() - .value(QStringLiteral("width")).toInt(), 3840); - } - - void rejectsUndersizedVisibleSurface() - { - const auto surface = StreamSurfaceController::encodeSurface({ - .windowRect = QRectF(4, 8, 20, 10), - .screenPosition = QPointF(4, 8), - .devicePixelRatio = 1.0, - .windowHandle = QStringLiteral("0x1"), - .visible = true, - }); - QVERIFY(!surface.value(QStringLiteral("visible")).toBool()); - QVERIFY(!surface.contains(QStringLiteral("windowHandle"))); - QCOMPARE(surface.value(QStringLiteral("rect")).toObject() - .value(QStringLiteral("width")).toInt(), 64); - QCOMPARE(surface.value(QStringLiteral("rect")).toObject() - .value(QStringLiteral("height")).toInt(), 64); - } - - void surfaceSignalsAreDeduplicatedAndTeardownIsHidden() - { - CoreClient core; - AppController app; - StreamSurfaceController controller(&core, &app); - QQuickWindow window; - window.resize(800, 450); - QQuickItem host(window.contentItem()); - host.setObjectName(QStringLiteral("streamSurfaceHost")); - host.setSize(QSizeF(800, 450)); - window.show(); - controller.setWindow(&window); - QCoreApplication::processEvents(); - - QSignalSpy changes(&controller, &StreamSurfaceController::surfaceChanged); - controller.setWindow(&window); - QCoreApplication::processEvents(); - QCOMPARE(changes.size(), 0); - - QVERIFY(app.navigate(QStringLiteral("stream"))); - QCoreApplication::processEvents(); - const auto afterRoute = changes.size(); - QVERIFY(afterRoute <= 1); - - host.setWidth(700); - QCoreApplication::processEvents(); - QCOMPARE(changes.size(), afterRoute + 1); - QCoreApplication::processEvents(); - QCOMPARE(changes.size(), afterRoute + 1); - - const auto visibleHostSurface = controller.surface(); - host.setVisible(false); - QCoreApplication::processEvents(); - QVERIFY(!controller.surface().value(QStringLiteral("visible")).toBool()); - QVERIFY(!controller.surface().contains(QStringLiteral("windowHandle"))); - host.setVisible(true); - QCoreApplication::processEvents(); - QCOMPARE(controller.surface(), visibleHostSurface); - - QVERIFY(app.showOverlay(QStringLiteral("stream-stats"))); - QVERIFY(!controller.surface().value(QStringLiteral("visible")).toBool()); - QVERIFY(!controller.surface().contains(QStringLiteral("windowHandle"))); - QVERIFY(app.showOverlay(QString{})); - QCOMPARE(controller.surface(), visibleHostSurface); - - controller.teardown(); - QCoreApplication::processEvents(); - QVERIFY(!controller.surface().value(QStringLiteral("visible")).toBool()); - QVERIFY(!controller.surface().contains(QStringLiteral("windowHandle"))); - const auto afterTeardown = changes.size(); - controller.teardown(); - QCOMPARE(changes.size(), afterTeardown); - } - - void nestedProductionStreamerEventActivatesUpdates() - { - auto fakeCore = QDir(QCoreApplication::applicationDirPath()) - .filePath(QStringLiteral("opennow-fake-core")); -#ifdef Q_OS_WIN - fakeCore += QStringLiteral(".exe"); -#endif - CoreClient core; - AppController app; - StreamSurfaceController controller(&core, &app); - QSignalSpy responses(&core, &CoreClient::responseReceived); - QVERIFY(core.start(fakeCore)); - QTRY_COMPARE_WITH_TIMEOUT(core.state(), QStringLiteral("ready"), 2'000); - QTRY_VERIFY_WITH_TIMEOUT(responses.size() >= 2, 2'000); - responses.clear(); - - const auto eventRequest = core.request(QStringLiteral("test.streamer-nested-event")); - QVERIFY(!eventRequest.isEmpty()); - QTRY_VERIFY_WITH_TIMEOUT(responses.size() >= 2, 2'000); - bool sawEventResponse = false; - bool sawSurfaceResponse = false; - for (const auto &arguments : responses) { - const auto id = arguments.at(0).toString(); - sawEventResponse |= id == eventRequest; - sawSurfaceResponse |= id != eventRequest - && arguments.at(1).toJsonObject().value(QStringLiteral("applied")).toBool(); - } - QVERIFY(sawEventResponse); - QVERIFY(sawSurfaceResponse); - } -}; - -QTEST_MAIN(StreamSurfaceControllerTest) -#include "tst_streamsurfacecontroller.moc" diff --git a/opennow-qt/tests/tst_streamvideoitem.cpp b/opennow-qt/tests/tst_streamvideoitem.cpp new file mode 100644 index 000000000..a501957f8 --- /dev/null +++ b/opennow-qt/tests/tst_streamvideoitem.cpp @@ -0,0 +1,184 @@ +#include "StreamVideoItem.h" +#include "NativeStreamRuntime.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +class TestRenderCallback final : public StreamVideoRenderCallback +{ +public: + void initialize(QRhi *rhi, + QRhiCommandBuffer *commandBuffer, + QRhiRenderTarget *renderTarget) override + { + validContext.store(rhi && commandBuffer && renderTarget); + ++initializeCount; + } + + void recordFrame(QRhiCommandBuffer *commandBuffer, const QRect &viewport) override + { + validContext.store(validContext.load() && commandBuffer); + viewportWidth.store(viewport.width()); + viewportHeight.store(viewport.height()); + ++frameCount; + } + + void prepareFrame(QRhiCommandBuffer *commandBuffer) override + { + validContext.store(validContext.load() && commandBuffer); + ++prepareCount; + } + + void finishFrame() override + { + ++finishCount; + } + + void releaseResources() override + { + ++releaseCount; + } + + std::atomic_bool validContext = false; + std::atomic_int initializeCount = 0; + std::atomic_int frameCount = 0; + std::atomic_int prepareCount = 0; + std::atomic_int finishCount = 0; + std::atomic_int releaseCount = 0; + std::atomic_int viewportWidth = 0; + std::atomic_int viewportHeight = 0; +}; + +class StreamVideoItemTest final : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() + { + registerStreamVideoItemQmlType(); + } + + void calculatesCenteredAspectFitViewport() + { + QCOMPARE(StreamVideoItem::aspectFitRect(QSize(1920, 1080), QSize(1000, 1000)), + QRect(0, 219, 1000, 562)); + QCOMPARE(StreamVideoItem::aspectFitRect(QSize(1000, 1000), QSize(1920, 1080)), + QRect(420, 0, 1080, 1080)); + QCOMPARE(StreamVideoItem::aspectFitRect(QSize(1280, 720), QSize(2560, 1440)), + QRect(0, 0, 2560, 1440)); + } + + void handlesUnknownAndInvalidSizesPredictably() + { + QCOMPARE(StreamVideoItem::aspectFitRect(QSize(), QSize(640, 360)), + QRect(0, 0, 640, 360)); + QCOMPARE(StreamVideoItem::aspectFitRect(QSize(1920, 1080), QSize()), QRect()); + } + + void mapsQtKeyboardStateToTypedGfnInputFields() + { + QCOMPARE(StreamVideoItem::windowsVirtualKey(Qt::Key_W), quint16(0x57)); + QCOMPARE(StreamVideoItem::windowsVirtualKey(Qt::Key_Escape), quint16(0x1b)); + QCOMPARE(StreamVideoItem::windowsVirtualKey(Qt::Key_F24), quint16(0x87)); + QCOMPARE(StreamVideoItem::windowsVirtualKey(Qt::Key_unknown), quint16(0)); + QCOMPARE(StreamVideoItem::inputModifiers( + Qt::ShiftModifier | Qt::ControlModifier, Qt::Key_W), quint16(0x03)); + QCOMPARE(StreamVideoItem::inputModifiers(Qt::ShiftModifier, Qt::Key_Shift), quint16(0)); + } + + void inputEnablementIsExplicitAndObservable() + { + StreamVideoItem item; + QSignalSpy changes(&item, &StreamVideoItem::inputEnabledChanged); + QVERIFY(item.inputEnabled()); + item.setInputEnabled(false); + QVERIFY(!item.inputEnabled()); + QCOMPARE(changes.size(), 1); + item.setInputEnabled(false); + QCOMPARE(changes.size(), 1); + } + + void normalizesVideoSizeAndTracksCallbackAvailability() + { + StreamVideoItem item; + QSignalSpy sizeChanges(&item, &StreamVideoItem::videoSizeChanged); + QSignalSpy callbackChanges(&item, &StreamVideoItem::renderCallbackAvailableChanged); + + item.setVideoSize(QSize(-1, 1080)); + QCOMPARE(item.videoSize(), QSize()); + QCOMPARE(sizeChanges.size(), 0); + + item.setVideoSize(QSize(1920, 1080)); + QCOMPARE(item.videoSize(), QSize(1920, 1080)); + QCOMPARE(sizeChanges.size(), 1); + + const auto callback = std::make_shared(); + item.setRenderCallback(callback); + QVERIFY(item.renderCallbackAvailable()); + QVERIFY(item.renderCallback() == callback); + QCOMPARE(callbackChanges.size(), 1); + + item.setRenderCallback(callback); + QCOMPARE(callbackChanges.size(), 1); + item.setRenderCallback({}); + QVERIFY(!item.renderCallbackAvailable()); + QCOMPARE(callbackChanges.size(), 2); + } + + void registersConcreteQmlSceneGraphType() + { + QVERIFY(qmlTypeId("OpenNOW", 1, 0, "StreamVideoItem") >= 0); + StreamVideoItem item; + QVERIFY(qobject_cast(&item)); + } + + void createsRenderCallbackFromTheSharedNativeRuntime() + { + NativeStreamRuntime runtime; + StreamVideoItem::setNativeStreamRuntime(&runtime); + { + StreamVideoItem item; + QCOMPARE(StreamVideoItem::nativeStreamRuntime(), &runtime); + QVERIFY(item.renderCallbackAvailable()); + } + StreamVideoItem::setNativeStreamRuntime(nullptr); + } + + void drivesCallbackThroughRhiSceneGraph() + { + if (QGuiApplication::platformName() == QStringLiteral("offscreen")) + QSKIP("The offscreen platform plugin does not create a QRhi."); + + const auto callback = std::make_shared(); + { + QQuickWindow window; + window.resize(640, 480); + auto *item = new StreamVideoItem(window.contentItem()); + item->setSize(QSizeF(640, 480)); + item->setVideoSize(QSize(1920, 1080)); + item->setRenderCallback(callback); + window.show(); + item->requestFrame(); + + QTRY_VERIFY_WITH_TIMEOUT(callback->initializeCount.load() > 0, 5'000); + QTRY_VERIFY_WITH_TIMEOUT(callback->frameCount.load() > 0, 5'000); + QVERIFY(callback->prepareCount.load() > 0); + QTRY_VERIFY_WITH_TIMEOUT(callback->finishCount.load() > 0, 5'000); + QVERIFY(callback->validContext.load()); + QCOMPARE(callback->viewportWidth.load(), 640); + QCOMPARE(callback->viewportHeight.load(), 360); + } + QTRY_VERIFY_WITH_TIMEOUT(callback->releaseCount.load() > 0, 5'000); + } +}; + +QTEST_MAIN(StreamVideoItemTest) +#include "tst_streamvideoitem.moc"