Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecfa71212d | |||
| 07e2836601 | |||
| 6ac7134e7c | |||
| 6d2e738070 | |||
| 09e2043ce0 | |||
| a513186424 | |||
| df6a5325d8 | |||
| 14c5e7c11c | |||
| 4cae1b8bb8 | |||
| b45323c0be | |||
| 1a7e3a6e4f | |||
| 4ffa2665ac | |||
| 2def3ef49e | |||
| 571e22bc0f | |||
| ce085b8e3b | |||
| 405b005a0d | |||
| 89a08f83af | |||
| 5748706631 | |||
| 2ae5cf98ee | |||
| e61d655b1e | |||
| 7099266594 | |||
| fd8a062e2c | |||
| 3c38a5f0e8 | |||
| 1f519d44f9 | |||
| abecb5226c | |||
| 11045a0f70 | |||
| d466e3e2b2 | |||
| 532b313b8c | |||
| d381cdf7f4 | |||
| f901bedf22 |
@@ -38,3 +38,8 @@ CLAUDE.md
|
||||
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||
.flatpak-repo/
|
||||
.flatpak-build/
|
||||
|
||||
# Nix build outputs (flake.nix) — `nix build` result symlinks + direnv cache. flake.lock IS tracked.
|
||||
/result
|
||||
/result-*
|
||||
.direnv/
|
||||
|
||||
Generated
+1
-1
@@ -3184,10 +3184,10 @@ dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
"libc",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
|
||||
/**
|
||||
@@ -249,11 +251,25 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
*/
|
||||
fun displaySupportsHdr(context: Context): Boolean {
|
||||
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
|
||||
val caps = display.hdrCapabilities ?: return false
|
||||
return caps.supportedHdrTypes.any {
|
||||
val types = buildSet {
|
||||
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
|
||||
// deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices
|
||||
// (Pixel-class panels included), which would make a genuinely HDR display advertise
|
||||
// no-HDR and pin the whole session to 8-bit SDR.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
display.mode.supportedHdrTypes.forEach { add(it) }
|
||||
}
|
||||
// Union the legacy query defensively — the supported one on minSdk 31, and some vendors
|
||||
// populate only this on newer APIs.
|
||||
@Suppress("DEPRECATION")
|
||||
display.hdrCapabilities?.supportedHdrTypes?.forEach { add(it) }
|
||||
}
|
||||
// HDR10/HDR10+ only: the stream is BT.2020 PQ — a Dolby-Vision/HLG-only panel can't present it.
|
||||
val supported = types.any {
|
||||
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
|
||||
}
|
||||
Log.i("punktfunk", "display HDR types=$types → advertise HDR10=$supported")
|
||||
return supported
|
||||
}
|
||||
|
||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||
|
||||
@@ -39,6 +39,7 @@ internal fun StatsOverlay(
|
||||
s: DoubleArray,
|
||||
verbosity: StatsVerbosity,
|
||||
decoderLabel: String = "",
|
||||
codecLabel: String = "",
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||
@@ -66,7 +67,7 @@ internal fun StatsOverlay(
|
||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||
}
|
||||
if (detailed) {
|
||||
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
||||
videoFeedLine(s, codecLabel)?.let { statLine(it, Color.White) }
|
||||
}
|
||||
if (latValid) {
|
||||
// Display stage (s[22]–s[25], from OnFrameRendered): when a render timestamp landed
|
||||
@@ -151,14 +152,15 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||
* Format the negotiated video-feed descriptor from [codecLabel] plus the trailing four stats
|
||||
* doubles `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
* `AV1 · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
|
||||
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
|
||||
* Android decoder is always HEVC (`video/hevc`).
|
||||
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4.
|
||||
* [codecLabel] is the host-resolved codec (`nativeVideoCodecLabel`); a blank one falls back to
|
||||
* `HEVC` (the pre-negotiation default) for the brief window before it's resolved.
|
||||
*/
|
||||
private fun videoFeedLine(s: DoubleArray): String? {
|
||||
private fun videoFeedLine(s: DoubleArray, codecLabel: String): String? {
|
||||
if (s.size < 14) return null
|
||||
val bitDepth = s[10].toInt()
|
||||
val primaries = s[11].toInt()
|
||||
@@ -175,5 +177,6 @@ private fun videoFeedLine(s: DoubleArray): String? {
|
||||
2 -> "4:2:2"
|
||||
else -> "4:2:0"
|
||||
}
|
||||
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||
val codec = codecLabel.ifEmpty { "HEVC" }
|
||||
return "$codec · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val initialSettings = remember { SettingsStore(context).load() }
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var decoderLabel by remember { mutableStateOf("") }
|
||||
var codecLabel by remember { mutableStateOf("") }
|
||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
@@ -99,6 +100,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
LaunchedEffect(handle, statsOn) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||
if (statsOn) {
|
||||
// Codec is resolved at the handshake (Welcome) — fixed for the session, so read its
|
||||
// label once up front (before the first snapshot renders the video-feed line).
|
||||
if (codecLabel.isEmpty()) codecLabel = NativeBridge.nativeVideoCodecLabel(handle)
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
@@ -366,7 +370,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||
if (statsOn) {
|
||||
stats?.let {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
||||
|
||||
@@ -214,6 +214,7 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
codecLabel = "HEVC",
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,6 +161,14 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeVideoMime(handle: Long): String
|
||||
|
||||
/**
|
||||
* A short human label for the codec the host resolved (`"H.264"` / `"HEVC"` / `"AV1"` /
|
||||
* `"PyroWave"`), for the stats HUD's video-feed line, or `""` on a `0` handle. Distinct from
|
||||
* [nativeVideoMime] because the MIME collapses PyroWave onto `video/hevc` and can't name it.
|
||||
* Fixed for the session (resolved at the handshake); read once. Cheap; UI-safe.
|
||||
*/
|
||||
external fun nativeVideoCodecLabel(handle: Long): String
|
||||
|
||||
/**
|
||||
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||
|
||||
@@ -161,7 +161,7 @@ fn run_sync(
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"decode: HEVC decoder started at {}x{}",
|
||||
"decode: {mime} decoder started at {}x{}",
|
||||
mode.width,
|
||||
mode.height
|
||||
);
|
||||
@@ -617,6 +617,19 @@ pub(crate) fn codec_mime(codec: u8) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A short human label for the codec the host resolved, for the stats HUD's video-feed line
|
||||
/// (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`). Mirrors [`codec_mime`]'s fallback: anything
|
||||
/// not H.264/AV1/PyroWave is reported as HEVC (every pre-negotiation host emitted HEVC). Kept
|
||||
/// beside [`codec_mime`] because the MIME collapses PyroWave onto `video/hevc` and so can't name it.
|
||||
pub(crate) fn codec_label(codec: u8) -> &'static str {
|
||||
match codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "H.264",
|
||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
||||
_ => "HEVC",
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList`
|
||||
/// (`from_codec_name`), falling back to the platform's default decoder for the MIME
|
||||
/// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update).
|
||||
@@ -815,7 +828,11 @@ fn run_async(
|
||||
})),
|
||||
on_error: Some(Box::new(move |e, code, _detail| {
|
||||
let fatal = !code.is_recoverable() && !code.is_transient();
|
||||
log::warn!("decode: codec error {e:?} (fatal={fatal})");
|
||||
if fatal {
|
||||
log::error!("decode: fatal codec error — stream will stop: {e:?}");
|
||||
} else {
|
||||
log::warn!("decode: codec error {e:?} (recoverable)");
|
||||
}
|
||||
let _ = err_tx.send(DecodeEvent::Error { fatal });
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -102,6 +102,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the
|
||||
/// host resolved (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`), for the stats HUD's video-feed
|
||||
/// line. Distinct from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime`] because the MIME
|
||||
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
|
||||
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match env.new_string(crate::decode::codec_label(h.client.codec)) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
|
||||
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
|
||||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||||
|
||||
@@ -19,5 +19,32 @@
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- Background keep-alive (opt-in, iOS/iPadOS): the ONLY sanctioned way to keep the long-lived
|
||||
QUIC socket + pump-thread set alive while backgrounded is the audio background mode, backed
|
||||
by the session's real, audible remote audio (AVAudioEngine keeps rendering). Video decode is
|
||||
dropped; a bounded timer auto-disconnects. Never silence-as-keepalive (App Review 2.5.4).
|
||||
tvOS ignores/tolerates the key; macOS is not gated by it. -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<!-- Live Activities (iOS/iPadOS): the Lock-Screen / Dynamic-Island session surface. Updated
|
||||
locally (pushType nil) from the alive app process — no aps-environment. tvOS/macOS ignore it. -->
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<!-- Deep links: punktfunk://connect/<host-uuid>[?launch=<GameEntry.id>]. Emitted by the
|
||||
launcher widget and Siri/Shortcuts; routed by ContentView.onOpenURL into the existing
|
||||
connect path. Shared across all three targets (tvOS/macOS accept it harmlessly). -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>io.unom.punktfunk.deeplink</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -73,5 +73,15 @@
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||
</array>
|
||||
|
||||
<!-- App Group: same shared UserDefaults suite as iOS (Config/Punktfunk.entitlements). Shared
|
||||
here so a single HostStore code path (UserDefaults(suiteName:)) works on every platform;
|
||||
macOS widgets that read it arrive with M5. macOS App Groups use the plain group id under
|
||||
the App Store profile; a Developer-ID-signed build wants the team-prefixed form — the
|
||||
Dev-ID codesign step in release.yml must verify this value against the Dev-ID profile. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -20,5 +20,14 @@
|
||||
is true on iOS/tvOS too. -->
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
<!-- App Group: the shared UserDefaults suite (group.io.unom.punktfunk) that both the app and
|
||||
the Widget/Live-Activity extension read — the saved-host store moved there so a launcher
|
||||
widget can see it (HostStore reads UserDefaults(suiteName:)). Must be registered on the
|
||||
developer portal and enabled in the provisioning profile for BOTH app ids
|
||||
(io.unom.punktfunk + io.unom.punktfunk.widgets). tvOS carries the key harmlessly. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -9,13 +9,20 @@ let package = Package(
|
||||
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],
|
||||
products: [
|
||||
.library(name: "PunktfunkKit", targets: ["PunktfunkKit"]),
|
||||
// Dependency-free foundation (stored-host model + JSON codec, settings keys, App-Group
|
||||
// constant, deep-link grammar, Live Activity attributes). A separate PRODUCT so the widget
|
||||
// extension — which must never link PunktfunkKit (Rust staticlib + presentation layer) —
|
||||
// can link this and nothing else. PunktfunkKit re-exports it (see SharedReexport.swift).
|
||||
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
|
||||
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
||||
],
|
||||
targets: [
|
||||
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
||||
// No dependencies by design — an extension process links this alone.
|
||||
.target(name: "PunktfunkShared"),
|
||||
.target(
|
||||
name: "PunktfunkKit",
|
||||
dependencies: ["PunktfunkCore"],
|
||||
dependencies: ["PunktfunkCore", "PunktfunkShared"],
|
||||
// OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the
|
||||
// app target) so it rides along via Bundle.module in both `swift build` and the Xcode
|
||||
// app, which links the PunktfunkKit product. Refresh with
|
||||
@@ -43,7 +50,8 @@ let package = Package(
|
||||
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||
.testTarget(
|
||||
name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"],
|
||||
name: "PunktfunkKitTests",
|
||||
dependencies: ["PunktfunkKit", "PunktfunkShared", "PunktfunkCore"],
|
||||
resources: [
|
||||
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
|
||||
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
|
||||
|
||||
@@ -11,14 +11,56 @@
|
||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
E29556A7300948BA009F939C /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA000000000000000000000D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = E2955696300948B9009F939C;
|
||||
remoteInfo = PunktfunkWidgetsExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
E29556AA300948BA009F939C /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CC0000000000000000000001 /* Punktfunk-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PunktfunkWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E2955699300948B9009F939C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
E295569B300948B9009F939C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PunktfunkWidgetsExtension.entitlements; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
AA0000000000000000000002 /* App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
@@ -30,6 +72,14 @@
|
||||
path = Sources/PunktfunkClient;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E295569D300948B9009F939C /* PunktfunkWidgets */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */,
|
||||
);
|
||||
path = PunktfunkWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -58,14 +108,27 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E2955694300948B9009F939C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */,
|
||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */,
|
||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
AA0000000000000000000007 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */,
|
||||
AA0000000000000000000002 /* App */,
|
||||
AA0000000000000000000003 /* Sources/PunktfunkClient */,
|
||||
E295569D300948B9009F939C /* PunktfunkWidgets */,
|
||||
E2955698300948B9009F939C /* Frameworks */,
|
||||
AA0000000000000000000008 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -76,10 +139,20 @@
|
||||
AA0000000000000000000001 /* Punktfunk.app */,
|
||||
BB0000000000000000000001 /* Punktfunk-iOS.app */,
|
||||
CC0000000000000000000001 /* Punktfunk-tvOS.app */,
|
||||
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2955698300948B9009F939C /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2955699300948B9009F939C /* WidgetKit.framework */,
|
||||
E295569B300948B9009F939C /* SwiftUI.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -114,10 +187,12 @@
|
||||
BB000000000000000000000B /* Sources */,
|
||||
BB0000000000000000000004 /* Frameworks */,
|
||||
BB000000000000000000000C /* Resources */,
|
||||
E29556AA300948BA009F939C /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E29556A8300948BA009F939C /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000002 /* App */,
|
||||
@@ -156,6 +231,29 @@
|
||||
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */;
|
||||
buildPhases = (
|
||||
E2955693300948B9009F939C /* Sources */,
|
||||
E2955694300948B9009F939C /* Frameworks */,
|
||||
E2955695300948B9009F939C /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
E295569D300948B9009F939C /* PunktfunkWidgets */,
|
||||
);
|
||||
name = PunktfunkWidgetsExtension;
|
||||
packageProductDependencies = (
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */,
|
||||
);
|
||||
productName = PunktfunkWidgetsExtension;
|
||||
productReference = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -163,11 +261,15 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2700;
|
||||
LastUpgradeCheck = 2700;
|
||||
TargetAttributes = {
|
||||
AA0000000000000000000009 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
E2955696300948B9009F939C = {
|
||||
CreatedOnToolsVersion = 27.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = AA000000000000000000000E /* Build configuration list for PBXProject "Punktfunk" */;
|
||||
@@ -190,6 +292,7 @@
|
||||
AA0000000000000000000009 /* Punktfunk */,
|
||||
BB0000000000000000000009 /* Punktfunk-iOS */,
|
||||
CC0000000000000000000009 /* Punktfunk-tvOS */,
|
||||
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -216,6 +319,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E2955695300948B9009F939C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -240,8 +350,23 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E2955693300948B9009F939C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
E29556A8300948BA009F939C /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
|
||||
targetProxy = E29556A7300948BA009F939C /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
AA0000000000000000000010 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -564,6 +689,97 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
E29556AB300948BA009F939C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E29556AC300948BA009F939C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -603,6 +819,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E29556AB300948BA009F939C /* Debug */,
|
||||
E29556AC300948BA009F939C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
@@ -636,6 +861,10 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkKit;
|
||||
};
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkShared;
|
||||
};
|
||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Home-Screen / Lock-Screen quick-launch widget (kind "PunktfunkHosts"). Reads the saved-host
|
||||
// store from the shared App-Group suite, sorts most-recent-first, and deep-links each host into a
|
||||
// session via `punktfunk://connect/<uuid>` — the app's onOpenURL routes it through the normal
|
||||
// connect path (trust policy / WoL / approval all apply).
|
||||
//
|
||||
// No reachability probing in v1 (a UDP check has no place in a timeline build; WoL handles offline
|
||||
// hosts on tap). Timeline is a single `.never` entry — the app pushes reloads on store changes
|
||||
// (HostStore → WidgetCenter.reloadTimelines).
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
struct HostsEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let hosts: [StoredHost]
|
||||
}
|
||||
|
||||
struct HostsProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> HostsEntry {
|
||||
HostsEntry(date: .now, hosts: [])
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (HostsEntry) -> Void) {
|
||||
completion(HostsEntry(date: .now, hosts: Self.loadHosts()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<HostsEntry>) -> Void) {
|
||||
// Single entry, never auto-refresh: the app reloads this timeline whenever the store
|
||||
// changes (a new host, a fresh connect reordering by recency).
|
||||
let entry = HostsEntry(date: .now, hosts: Self.loadHosts())
|
||||
completion(Timeline(entries: [entry], policy: .never))
|
||||
}
|
||||
|
||||
/// Decode the shared-suite host JSON (same wire format the app writes), most-recent first.
|
||||
static func loadHosts() -> [StoredHost] {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return [] }
|
||||
return hosts.sorted {
|
||||
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct HostsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "PunktfunkHosts", provider: HostsProvider()) { entry in
|
||||
HostsWidgetView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("Punktfunk Hosts")
|
||||
.description("Quick-launch your recent streaming hosts.")
|
||||
.supportedFamilies([
|
||||
.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
struct HostsWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: HostsEntry
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .systemMedium:
|
||||
MediumHostsView(hosts: entry.hosts)
|
||||
case .accessoryCircular:
|
||||
CircularHostView(host: entry.hosts.first)
|
||||
case .accessoryRectangular:
|
||||
RectangularHostView(host: entry.hosts.first)
|
||||
default: // systemSmall + fallback
|
||||
SmallHostView(host: entry.hosts.first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep link that connects to a stored host.
|
||||
private func connectURL(_ host: StoredHost) -> URL {
|
||||
DeepLink.connect(host: host.id, launchID: nil).url
|
||||
}
|
||||
|
||||
private struct SmallHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
if let host {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
Spacer(minLength: 0)
|
||||
Text(host.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if let last = host.lastConnected {
|
||||
Text(last, format: .relative(presentation: .named))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.widgetURL(connectURL(host))
|
||||
} else {
|
||||
EmptyHostView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediumHostsView: View {
|
||||
let hosts: [StoredHost]
|
||||
var body: some View {
|
||||
if hosts.isEmpty {
|
||||
EmptyHostView()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Punktfunk")
|
||||
.font(.caption).bold()
|
||||
.foregroundStyle(.tint)
|
||||
ForEach(hosts.prefix(4)) { host in
|
||||
Link(destination: connectURL(host)) {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(.tint)
|
||||
Text(host.displayName)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if let last = host.lastConnected {
|
||||
Text(last, format: .relative(presentation: .named))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
Image(systemName: "play.tv.fill")
|
||||
}
|
||||
.widgetURL(host.map(connectURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct RectangularHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
Text(host?.displayName ?? "Punktfunk")
|
||||
.lineLimit(1)
|
||||
}
|
||||
.widgetURL(host.map(connectURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyHostView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "play.tv")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Open Punktfunk to add a host.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
// The widget extension's entry point. ONE extension target (bundle id io.unom.punktfunk.widgets,
|
||||
// iOS only) hosts both the launcher widgets and the Live Activity UI. It links PunktfunkShared and
|
||||
// NOTHING else — never PunktfunkKit (Rust staticlib + presentation layer would blow the widget
|
||||
// process's ~30 MB budget).
|
||||
//
|
||||
// These files are NOT part of the SwiftPM package (Package.swift doesn't declare a PunktfunkWidgets
|
||||
// target, so `swift build` ignores the directory). They compile only in the Xcode widget-extension
|
||||
// target you add pointing at this folder — see design/apple-live-activities-and-widgets.md §M1 and
|
||||
// the GUI checklist.
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct PunktfunkWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
HostsWidget()
|
||||
PunktfunkSessionLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// The Live Activity UI (Lock Screen banner + Dynamic Island) for a running session. The app owns
|
||||
// the Activity's lifecycle (SessionActivityController); this is only its presentation, rendered in
|
||||
// the widget-extension process from the shared `PunktfunkSessionAttributes`.
|
||||
//
|
||||
// The End button runs `EndStreamIntent` (a LiveActivityIntent) IN THE APP's process, which posts
|
||||
// .punktfunkEndActiveSession → the app disconnects. Elapsed time ticks client-side via
|
||||
// Text(timerInterval:) — no per-second push.
|
||||
|
||||
import ActivityKit
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
struct PunktfunkSessionLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: PunktfunkSessionAttributes.self) { context in
|
||||
LockScreenView(context: context)
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label {
|
||||
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: "play.tv.fill")
|
||||
}
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.caption).monospacedDigit()
|
||||
.frame(maxWidth: 56)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(spacing: 6) {
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
EndButton()
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
} compactTrailing: {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: 44)
|
||||
} minimal: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen banner
|
||||
|
||||
private struct LockScreenView: View {
|
||||
let context: ActivityViewContext<PunktfunkSessionAttributes>
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.subheadline).monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
}
|
||||
if context.state.stage == .background {
|
||||
EndButton()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared pieces
|
||||
|
||||
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
||||
private struct StageLine: View {
|
||||
let state: PunktfunkSessionAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
switch state.stage {
|
||||
case .streaming:
|
||||
EmptyView()
|
||||
case .background:
|
||||
if let deadline = state.backgroundDeadline {
|
||||
HStack(spacing: 3) {
|
||||
Text("Keeps running for")
|
||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
badge("Running in background", .orange)
|
||||
}
|
||||
case .reconnecting:
|
||||
badge("Reconnecting…", .yellow)
|
||||
case .ending:
|
||||
badge("Session ended", .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func badge(_ text: String, _ color: Color) -> some View {
|
||||
Text(text).font(.caption2).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
|
||||
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
||||
private struct EndButton: View {
|
||||
var body: some View {
|
||||
Button(intent: EndStreamIntent()) {
|
||||
Label("End", systemImage: "stop.fill")
|
||||
.font(.caption).bold()
|
||||
}
|
||||
.tint(.red)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -51,6 +51,15 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
@State private var showAddHost = false
|
||||
/// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored — unknown host, or
|
||||
/// a live session is already up. Surfaced as an informational alert (distinct from the
|
||||
/// "Connection failed" one, which is for actual connect errors).
|
||||
@State private var deepLinkNotice: String?
|
||||
#if os(iOS)
|
||||
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
||||
/// the session model's published state below; iPhone/iPad only.
|
||||
@State private var liveActivity = SessionActivityController()
|
||||
#endif
|
||||
@State private var pairingTarget: StoredHost?
|
||||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
|
||||
@@ -92,6 +101,14 @@ struct ContentView: View {
|
||||
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
/// Background keep-alive (Settings → General, iOS-only). Default OFF (today's freeze-on-background
|
||||
/// is the default). When on, backgrounding a live session keeps audio + the connection alive and
|
||||
/// drops video, auto-disconnecting after `backgroundTimeoutMinutes`.
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) private var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) private var backgroundTimeoutMinutes = 10
|
||||
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers — resign-active
|
||||
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
@@ -113,7 +130,62 @@ struct ContentView: View {
|
||||
.onAppear {
|
||||
seedDefaultModeIfNeeded()
|
||||
autoConnectIfAsked()
|
||||
#if os(iOS)
|
||||
SessionActivityController.sweepOrphans() // end any Activity a prior killed launch left
|
||||
#endif
|
||||
}
|
||||
// Deep links (widget quick-launch, Siri/Shortcuts): route into the SAME connect path a card
|
||||
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
|
||||
// parallel session — this drives the one `model` ContentView owns.
|
||||
.onOpenURL { handleDeepLink($0) }
|
||||
#if os(iOS)
|
||||
// Background keep-alive driver (opt-in). Only .background/.active matter; .inactive (a
|
||||
// transient peek) is ignored so the disconnect timer never starts for a Control-Center pull.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .background:
|
||||
if backgroundKeepAlive, model.phase == .streaming {
|
||||
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
|
||||
}
|
||||
case .active:
|
||||
model.exitBackground()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
// Live Activity lifecycle, driven from the model's published state.
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
if let host = model.activeHost {
|
||||
liveActivity.begin(
|
||||
hostID: host.id, hostName: host.displayName,
|
||||
launchTitle: nil, // no live foreground-app title mid-session (v1)
|
||||
modeLine: currentModeLine(), startedAt: Date())
|
||||
}
|
||||
case .idle:
|
||||
liveActivity.end()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onChange(of: model.isBackgrounded) { _, backgrounded in
|
||||
liveActivity.update {
|
||||
$0.stage = backgrounded ? .background : .streaming
|
||||
$0.backgroundDeadline = model.backgroundDeadline
|
||||
}
|
||||
}
|
||||
// The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts
|
||||
// this — tear the session down deliberately (quit-close the host).
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in
|
||||
model.disconnect(deliberate: true)
|
||||
}
|
||||
// Connect App Intent (Siri/Shortcuts): route its punktfunk:// URL through the same handler
|
||||
// as a widget tap.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in
|
||||
if let url = note.object as? URL { handleDeepLink(url) }
|
||||
}
|
||||
#endif
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
@@ -262,6 +334,57 @@ struct ContentView: View {
|
||||
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||
+ "approve it — no need to reconnect.")
|
||||
}
|
||||
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
||||
.alert(
|
||||
"Can't open",
|
||||
isPresented: Binding(
|
||||
get: { deepLinkNotice != nil },
|
||||
set: { if !$0 { deepLinkNotice = nil } })
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(deepLinkNotice ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection.
|
||||
private func currentModeLine() -> String {
|
||||
guard let c = model.connection else { return "" }
|
||||
let codec: String
|
||||
switch c.videoCodec {
|
||||
case .h264: codec = "H.264"
|
||||
case .hevc: codec = "HEVC"
|
||||
case .av1: codec = "AV1"
|
||||
case .pyrowave: codec = "PyroWave"
|
||||
}
|
||||
var line = "\(c.width)×\(c.height)"
|
||||
if c.refreshHz > 0 { line += " @\(c.refreshHz)" }
|
||||
line += " · \(codec)"
|
||||
if c.isHDR { line += " · HDR" }
|
||||
return line
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Route a `punktfunk://` deep link into the existing connect path. Rules (per design):
|
||||
/// unknown host → notice + no-op; a live session is up → ignore if it's the same host, else
|
||||
/// tell the user to end the current one first (NEVER tear down a live session on a background
|
||||
/// tap); otherwise the normal `connect` — trust policy, WoL and the approval sheet all apply.
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
guard case let .connect(hostID, launchID)? = DeepLink(url) else { return }
|
||||
guard let host = store.hosts.first(where: { $0.id == hostID }) else {
|
||||
deepLinkNotice = "That host isn't saved on this device."
|
||||
return
|
||||
}
|
||||
if model.phase != .idle {
|
||||
guard model.activeHost?.id == hostID else {
|
||||
let current = model.activeHost?.displayName ?? "a host"
|
||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||
return
|
||||
}
|
||||
return // deep-linked to the host we're already on — nothing to do
|
||||
}
|
||||
connect(host, launchID: launchID)
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Siri / Shortcuts / Spotlight surface (design §M4). Deliberately thin: every action already has an
|
||||
// internal entry point — M0's deep-link router (connect / connect-and-launch), M3's in-process
|
||||
// end-session hook, and the existing Wake-on-LAN path — so these intents only wrap them.
|
||||
//
|
||||
// Gated os(iOS): the AppShortcutsProvider bundles `EndStreamIntent`, which is a LiveActivityIntent
|
||||
// (iPhone/iPad only). Connect/Wake themselves are plain AppIntents; they live here with the
|
||||
// provider rather than being split across platforms. `HostEntity` (the parameter type) is in
|
||||
// PunktfunkShared so the widget's configuration intent can share it.
|
||||
|
||||
#if os(iOS)
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
|
||||
/// Load a full saved host (MACs, address) from the shared App-Group store by id — HostEntity only
|
||||
/// carries id + name.
|
||||
private func loadStoredHost(_ id: UUID) -> StoredHost? {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return nil }
|
||||
return hosts.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Start a session with a stored host (optionally launching a title). Foregrounds the app and
|
||||
/// routes through the SAME `.onOpenURL` path a widget tap uses — trust policy, WoL and the approval
|
||||
/// sheet all apply, and its guards (unknown host, already-streaming) hold.
|
||||
struct ConnectToHostIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Connect to Host"
|
||||
static let description = IntentDescription("Start a Punktfunk streaming session with a host.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Host") var host: HostEntity
|
||||
@Parameter(title: "Game ID", description: "Optional store id like steam:570")
|
||||
var launchID: String?
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
let url = DeepLink.connect(host: host.id, launchID: launchID).url
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake a sleeping host (magic packet). No `openAppWhenRun` — usable in automations ("when I get
|
||||
/// home, wake the tower") without foregrounding the app.
|
||||
struct WakeHostIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Wake Host"
|
||||
static let description = IntentDescription("Send a Wake-on-LAN magic packet to a host.")
|
||||
|
||||
@Parameter(title: "Host") var host: HostEntity
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let stored = loadStoredHost(host.id), !stored.wakeMacs.isEmpty else {
|
||||
throw IntentError.noWakeAddress
|
||||
}
|
||||
PunktfunkConnection.wakeOnLAN(macs: stored.wakeMacs, lastKnownIP: stored.address)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to Siri/Shortcuts. `CustomLocalizedStringResourceConvertible` makes the message
|
||||
/// show as the intent's failure text.
|
||||
enum IntentError: Error, CustomLocalizedStringResourceConvertible {
|
||||
case noWakeAddress
|
||||
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
switch self {
|
||||
case .noWakeAddress:
|
||||
// One string LITERAL — LocalizedStringResource is ExpressibleByStringLiteral, but a
|
||||
// `"…" + "…"` concatenation is a runtime String it can't convert.
|
||||
return "That host has no saved Wake-on-LAN address yet. Connect to it once so Punktfunk can learn it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays
|
||||
/// well under the 10-shortcut cap.
|
||||
struct PunktfunkShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: ConnectToHostIntent(),
|
||||
phrases: [
|
||||
"Connect to \(\.$host) in \(.applicationName)",
|
||||
"Stream \(\.$host) with \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Connect", systemImageName: "play.tv.fill")
|
||||
AppShortcut(
|
||||
intent: WakeHostIntent(),
|
||||
phrases: [
|
||||
"Wake \(\.$host) with \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Wake Host", systemImageName: "power")
|
||||
AppShortcut(
|
||||
intent: EndStreamIntent(),
|
||||
phrases: [
|
||||
"End the \(.applicationName) stream",
|
||||
],
|
||||
shortTitle: "End Stream", systemImageName: "stop.fill")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,89 @@
|
||||
// Owns the ActivityKit Live Activity lifecycle for a streaming session (iPhone/iPad only). Driven
|
||||
// by ContentView from the session model's published state (phase / isBackgrounded / deadline) so
|
||||
// none of this leaks into the cross-platform SessionModel. Local updates only (`pushType: nil`) —
|
||||
// the app process is alive whenever there's a session to report, so there's no push token plumbing.
|
||||
//
|
||||
// Gated os(iOS): ActivityKit is iPhone/iPad only. Minimum deployment is iOS 17, so no @available
|
||||
// guards are needed (Activity has existed since 16.1).
|
||||
|
||||
#if os(iOS)
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
// PunktfunkKit re-exports PunktfunkShared (@_exported), so the app target sees PunktfunkSessionAttributes
|
||||
// without linking the Shared product directly — same pattern as StoredHost in HostStore.
|
||||
import PunktfunkKit
|
||||
|
||||
@MainActor
|
||||
final class SessionActivityController {
|
||||
private var activity: Activity<PunktfunkSessionAttributes>?
|
||||
/// The last pushed state, so an update can mutate one field and keep the rest (notably
|
||||
/// `startedAt`, which the Lock-Screen timer ticks from).
|
||||
private var state: PunktfunkSessionAttributes.ContentState?
|
||||
|
||||
/// How far past the next expected update to mark the content stale — a frozen opt-out session
|
||||
/// then greys out instead of showing a lying clock.
|
||||
private static let staleWindow: TimeInterval = 90
|
||||
|
||||
var isActive: Bool { activity != nil }
|
||||
|
||||
/// End any Activity left over from a previous launch that was killed mid-session. Call once at
|
||||
/// app start (ContentView.onAppear).
|
||||
static func sweepOrphans() {
|
||||
Task {
|
||||
for activity in Activity<PunktfunkSessionAttributes>.activities {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the Live Activity for a freshly-streaming session. No-op if the user disabled Live
|
||||
/// Activities for the app, or one is already up.
|
||||
func begin(hostID: UUID, hostName: String, launchTitle: String?, modeLine: String, startedAt: Date) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled, activity == nil else { return }
|
||||
let attributes = PunktfunkSessionAttributes(
|
||||
hostID: hostID, hostName: hostName, launchTitle: launchTitle)
|
||||
let initial = PunktfunkSessionAttributes.ContentState(
|
||||
stage: .streaming, startedAt: startedAt, modeLine: modeLine)
|
||||
state = initial
|
||||
do {
|
||||
activity = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: content(initial),
|
||||
pushType: nil)
|
||||
} catch {
|
||||
activity = nil
|
||||
state = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Coalesced update: mutate the running state in place (keeps `startedAt` etc.) and push once.
|
||||
/// No-op when there's no live Activity.
|
||||
func update(_ mutate: (inout PunktfunkSessionAttributes.ContentState) -> Void) {
|
||||
guard let activity, var next = state else { return }
|
||||
mutate(&next)
|
||||
state = next
|
||||
Task { await activity.update(content(next)) }
|
||||
}
|
||||
|
||||
/// End with a final "ended" state, dismissed a few seconds later.
|
||||
func end() {
|
||||
guard let activity, var final = state else {
|
||||
self.activity = nil
|
||||
state = nil
|
||||
return
|
||||
}
|
||||
self.activity = nil
|
||||
state = nil
|
||||
final.stage = .ending
|
||||
final.backgroundDeadline = nil
|
||||
Task {
|
||||
await activity.end(content(final), dismissalPolicy: .after(.now + 4))
|
||||
}
|
||||
}
|
||||
|
||||
private func content(_ s: PunktfunkSessionAttributes.ContentState)
|
||||
-> ActivityContent<PunktfunkSessionAttributes.ContentState> {
|
||||
ActivityContent(state: s, staleDate: Date().addingTimeInterval(Self.staleWindow))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -148,6 +148,16 @@ final class SessionModel: ObservableObject {
|
||||
|
||||
var isBusy: Bool { phase != .idle }
|
||||
|
||||
/// True while a streaming session is running in the background under the opt-in keep-alive
|
||||
/// (audio plays, video dropped, timeout armed). Drives the Live Activity's stage/countdown (M3)
|
||||
/// and is cleared on foreground or teardown. iOS/iPadOS only in practice.
|
||||
@Published private(set) var isBackgrounded = false
|
||||
/// When the backgrounded keep-alive will auto-disconnect (nil unless backgrounded) — drives the
|
||||
/// Live Activity countdown. Set alongside `backgroundTimer`.
|
||||
@Published private(set) var backgroundDeadline: Date?
|
||||
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
|
||||
private var backgroundTimer: DispatchSourceTimer?
|
||||
|
||||
/// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true
|
||||
/// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host
|
||||
/// — `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair`
|
||||
@@ -332,6 +342,48 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background keep-alive (opt-in, iOS)
|
||||
|
||||
/// Enter the backgrounded keep-alive state: keep audio playing, DROP video decode (no GPU work
|
||||
/// off-screen), mute the mic (privacy), and arm a bounded auto-disconnect. The caller
|
||||
/// (ContentView's scenePhase driver) gates this on the setting + `.streaming`; a no-op otherwise.
|
||||
/// The video-drop seam is read by both pumps every iteration (`connection.isVideoDropped`).
|
||||
func enterBackground(timeoutMinutes: Int) {
|
||||
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
|
||||
isBackgrounded = true
|
||||
conn.setVideoDropped(true)
|
||||
audio?.setMicMuted(true)
|
||||
// Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast,
|
||||
// exactly like today's network-drop path. min 1 minute guards a nonsense setting.
|
||||
let minutes = max(1, timeoutMinutes)
|
||||
backgroundDeadline = Date().addingTimeInterval(TimeInterval(minutes * 60))
|
||||
let timer = DispatchSource.makeTimerSource(queue: .main)
|
||||
timer.schedule(deadline: .now() + .seconds(minutes * 60))
|
||||
timer.setEventHandler { [weak self] in
|
||||
// The timer fires on `.main`, so the actor's executor is the main thread here.
|
||||
MainActor.assumeIsolated { self?.disconnect(deliberate: false) }
|
||||
}
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = timer
|
||||
timer.resume()
|
||||
}
|
||||
|
||||
/// Return to foreground: cancel the timeout, resume mic + video, and force a clean re-anchor —
|
||||
/// request a fresh IDR (infinite GOP: it won't come on its own) and let the pump's freeze gate
|
||||
/// withhold the concealed frames until it lands (it auto-arms on the resumed frame-index gap).
|
||||
func exitBackground() {
|
||||
guard isBackgrounded else { return }
|
||||
isBackgrounded = false
|
||||
backgroundDeadline = nil
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = nil
|
||||
audio?.setMicMuted(false)
|
||||
if let conn = connection {
|
||||
conn.setVideoDropped(false)
|
||||
conn.requestKeyframe()
|
||||
}
|
||||
}
|
||||
|
||||
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
||||
func confirmTrust() -> Data? {
|
||||
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
|
||||
@@ -349,6 +401,11 @@ final class SessionModel: ObservableObject {
|
||||
func disconnect(deliberate: Bool = true) {
|
||||
statsTimer?.invalidate()
|
||||
statsTimer = nil
|
||||
// Drop any armed background keep-alive (incl. the timeout that just fired us).
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = nil
|
||||
isBackgrounded = false
|
||||
backgroundDeadline = nil
|
||||
let audio = self.audio
|
||||
self.audio = nil
|
||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||
|
||||
@@ -440,6 +440,34 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS/iPadOS only: keep a backgrounded session alive (audio background mode). Empty elsewhere
|
||||
/// (tvOS backgrounding semantics differ; macOS isn't gated by the mode) so the shared `.general`
|
||||
/// detail can reference it unconditionally.
|
||||
@ViewBuilder var keepAliveSection: some View {
|
||||
#if os(iOS)
|
||||
Section {
|
||||
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
||||
if backgroundKeepAlive {
|
||||
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
||||
Text("1 minute").tag(1)
|
||||
Text("5 minutes").tag(5)
|
||||
Text("10 minutes").tag(10)
|
||||
Text("30 minutes").tag(30)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Background")
|
||||
} footer: {
|
||||
Text("Off by default: backgrounding the app freezes the session. When on, audio keeps "
|
||||
+ "playing and the connection stays live (video is dropped to save power) after you "
|
||||
+ "switch away — and the session auto-disconnects after the time above so it can't "
|
||||
+ "run down your battery. Returning to the app resumes video instantly.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var experimentalSection: some View {
|
||||
Section {
|
||||
Toggle("Show game library", isOn: $libraryEnabled)
|
||||
|
||||
@@ -49,6 +49,8 @@ struct SettingsView: View {
|
||||
@ObservedObject var gamepads = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State var showControllerTest = false
|
||||
#endif
|
||||
@@ -242,6 +244,7 @@ struct SettingsView: View {
|
||||
pointerSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
keepAliveSection // iOS-only content; empty on tvOS
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
|
||||
@@ -11,32 +11,13 @@
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if canImport(WidgetKit)
|
||||
import WidgetKit
|
||||
#endif
|
||||
|
||||
struct StoredHost: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
var address: String
|
||||
var port: UInt16 = 9777
|
||||
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
|
||||
var pinnedSHA256: Data?
|
||||
/// Last time a streaming session actually started (nil until the first one).
|
||||
var lastConnected: Date?
|
||||
/// Management-API port for the library browser (distinct from the data-plane `port`). Optional
|
||||
/// (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks this key — still
|
||||
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
||||
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
||||
var mgmtPort: UInt16?
|
||||
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
|
||||
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
|
||||
/// client can send a magic packet to wake the host later (when it's asleep and no longer
|
||||
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
|
||||
var macAddresses: [String]?
|
||||
|
||||
var displayName: String { name.isEmpty ? address : name }
|
||||
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
|
||||
var wakeMacs: [String] { macAddresses ?? [] }
|
||||
}
|
||||
// `StoredHost` (the model + its JSON codec) now lives in PunktfunkShared so the widget extension
|
||||
// can read the same store; PunktfunkKit re-exports it. The discovery-join helpers below stay here
|
||||
// because they reference PunktfunkKit's `DiscoveredHost`/`HostDiscovery`.
|
||||
|
||||
extension StoredHost {
|
||||
/// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host — drives the
|
||||
@@ -86,8 +67,14 @@ final class HostStore: ObservableObject {
|
||||
/// never advertises still reads Online. Not persisted (it's live reachability, not config).
|
||||
@Published var probedOnline: Set<StoredHost.ID> = []
|
||||
|
||||
/// The App-Group suite — shared with the Widget/Live-Activity extension so a launcher widget
|
||||
/// sees the same saved hosts. Falls back to `.standard` in an un-entitled process (see
|
||||
/// `AppGroup.defaults`).
|
||||
private let defaults = AppGroup.defaults
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: Self.key),
|
||||
Self.migrateToAppGroupIfNeeded()
|
||||
if let data = defaults.data(forKey: Self.key),
|
||||
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
|
||||
hosts = decoded
|
||||
} else {
|
||||
@@ -95,6 +82,20 @@ final class HostStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time move of the saved-host JSON from `UserDefaults.standard` (where every build before
|
||||
/// the App Group wrote it) into the shared suite. Idempotent: only fires when the suite has no
|
||||
/// hosts yet but standard does. The old value is LEFT in place — during a staged TestFlight
|
||||
/// rollout an older build still reads `.standard`, so tombstoning it now would hide hosts from
|
||||
/// the not-yet-updated app. Remove the standard copy a release later.
|
||||
private static func migrateToAppGroupIfNeeded() {
|
||||
let suite = AppGroup.defaults
|
||||
let standard = UserDefaults.standard
|
||||
guard suite !== standard else { return } // un-entitled fallback: nothing to migrate
|
||||
guard suite.data(forKey: key) == nil,
|
||||
let legacy = standard.data(forKey: key) else { return }
|
||||
suite.set(legacy, forKey: key)
|
||||
}
|
||||
|
||||
func add(_ host: StoredHost) {
|
||||
hosts.append(host)
|
||||
}
|
||||
@@ -112,7 +113,7 @@ final class HostStore: ObservableObject {
|
||||
|
||||
func markConnected(_ hostID: UUID) {
|
||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
||||
hosts[i].lastConnected = Date()
|
||||
hosts[i].lastConnected = Date() // didSet → persist() writes the shared suite + reloads widget
|
||||
}
|
||||
|
||||
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
|
||||
@@ -158,7 +159,17 @@ final class HostStore: ObservableObject {
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
UserDefaults.standard.set(data, forKey: Self.key)
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
|
||||
}
|
||||
|
||||
/// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/
|
||||
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
|
||||
/// `.never`-refresh entries and relies on this push.
|
||||
private func reloadHostsWidget() {
|
||||
#if canImport(WidgetKit) && os(iOS)
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,23 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background keep-alive: silence the mic uplink while backgrounded (privacy — no room audio
|
||||
/// leaves the device) and restore it on return. Pauses/resumes the capture engine; a no-op when
|
||||
/// there's no uplink (playback-only / tvOS / mic disabled). The audio SESSION stays active for
|
||||
/// background playback, so iOS may keep showing the recording indicator until a full reconfigure
|
||||
/// — this stops the actual capture, which is the privacy-relevant part. Main thread.
|
||||
public func setMicMuted(_ muted: Bool) {
|
||||
stateLock.lock()
|
||||
let capture = captureEngine
|
||||
stateLock.unlock()
|
||||
guard let capture else { return }
|
||||
if muted {
|
||||
capture.pause()
|
||||
} else if !flag.isStopped {
|
||||
try? capture.start()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playback (host → speaker)
|
||||
|
||||
private func startPlayback(speakerUID: String) {
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
||||
|
||||
import Foundation
|
||||
// `punktfunkDefaultMgmtPort` (and StoredHost/DefaultsKey) now live in PunktfunkShared so the
|
||||
// dependency-free widget extension can share them; PunktfunkKit re-exports the module.
|
||||
import PunktfunkShared
|
||||
|
||||
/// Cover art URLs (the public Steam CDN for Steam titles, user-supplied for custom entries).
|
||||
public struct Artwork: Codable, Hashable, Sendable {
|
||||
@@ -64,10 +67,6 @@ public enum LibraryError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
/// The management API's default port — adjacent to the GameStream block; matches
|
||||
/// `mgmt::DEFAULT_PORT` on the host.
|
||||
public let punktfunkDefaultMgmtPort: UInt16 = 47990
|
||||
|
||||
/// Stateless fetcher for a host's library.
|
||||
public enum LibraryClient {
|
||||
/// `GET https://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
|
||||
|
||||
@@ -534,6 +534,23 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_request_keyframe(h)
|
||||
}
|
||||
|
||||
/// Background-keep-alive video drop (opt-in). While true, both video pumps keep DRAINING
|
||||
/// `nextAU()` (so QUIC flow control and host pacing stay healthy) but DISCARD each AU before any
|
||||
/// VideoToolbox/Metal decode or render — the crash/jetsam-safe way to hold a backgrounded
|
||||
/// session (audio keeps rendering; no GPU work off-screen). Set on `SessionModel.enterBackground`,
|
||||
/// cleared on `exitBackground` (which then requests a fresh IDR; the pump's re-anchor gate
|
||||
/// auto-arms on the resumed frame-index gap). Its own tiny lock — read on the pump thread every
|
||||
/// iteration, written on the main actor; never contends the ABI/plane locks.
|
||||
private let videoDropLock = NSLock()
|
||||
private var videoDropped = false
|
||||
public var isVideoDropped: Bool {
|
||||
videoDropLock.lock(); defer { videoDropLock.unlock() }
|
||||
return videoDropped
|
||||
}
|
||||
public func setVideoDropped(_ dropped: Bool) {
|
||||
videoDropLock.lock(); videoDropped = dropped; videoDropLock.unlock()
|
||||
}
|
||||
|
||||
/// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a
|
||||
/// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap —
|
||||
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
|
||||
|
||||
@@ -23,6 +23,7 @@ import Combine
|
||||
import CoreHaptics
|
||||
import Foundation
|
||||
import GameController
|
||||
import PunktfunkShared
|
||||
|
||||
public final class GamepadFeedback {
|
||||
private let connection: PunktfunkConnection
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import GameController
|
||||
import PunktfunkShared
|
||||
|
||||
@MainActor
|
||||
public final class GamepadManager: ObservableObject {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
public enum GamepadUIEnvironment {
|
||||
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
import PunktfunkShared
|
||||
import UIKit
|
||||
|
||||
/// How touchscreen fingers drive the host — persisted under `DefaultsKey.touchMode`, latched
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// PunktfunkShared holds what the app AND the widget extension both need — the stored-host model,
|
||||
// the settings-key names, the App-Group constant, the deep-link grammar, and the Live Activity
|
||||
// attributes — in a module that links neither the Rust core nor the presentation layer.
|
||||
//
|
||||
// Re-export it so every existing consumer of PunktfunkKit (`import PunktfunkKit`) keeps seeing
|
||||
// `StoredHost`, `DefaultsKey`, `punktfunkDefaultMgmtPort`, `DeepLink`, etc. with no call-site churn.
|
||||
// (Files INSIDE PunktfunkKit still `import PunktfunkShared` explicitly — Swift imports are
|
||||
// file-scoped; the re-export only reaches downstream modules.)
|
||||
@_exported import PunktfunkShared
|
||||
@@ -8,6 +8,7 @@
|
||||
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
||||
/// rename the cases freely, never the strings.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
import QuartzCore
|
||||
#if os(tvOS)
|
||||
import UIKit
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Metal
|
||||
import PunktfunkShared
|
||||
import QuartzCore
|
||||
|
||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
||||
@@ -431,6 +432,15 @@ public final class Stage2Pipeline {
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Background keep-alive: drain one AU (flow control + host pacing) and discard it
|
||||
// BEFORE any VideoToolbox decode or Metal render — no GPU work off-screen. The
|
||||
// decoder session is left intact; exitBackground requests a fresh IDR and the
|
||||
// re-anchor gate arms on the resumed frame-index gap so concealed frames are
|
||||
// withheld until it lands.
|
||||
if connection.isVideoDropped {
|
||||
_ = try connection.nextAU(timeoutMs: 100)
|
||||
return true
|
||||
}
|
||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||
// so key off the drop count climbing, then keep asking (awaitingIDR) until a fresh
|
||||
@@ -686,6 +696,13 @@ public final class Stage2Pipeline {
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Background keep-alive: drain + discard before the Metal wavelet decode
|
||||
// (PyroWave is all-intra, so the resumed frame heals on its own — no IDR
|
||||
// request needed, just no GPU work off-screen).
|
||||
if connection.isVideoDropped {
|
||||
_ = try connection.nextAU(timeoutMs: 100)
|
||||
return true
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||
onFrame?(au)
|
||||
if let newest = newestIndex,
|
||||
|
||||
@@ -63,6 +63,14 @@ final class StreamPump {
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Background keep-alive: drain one AU to keep QUIC flow control + host pacing
|
||||
// healthy, then discard it BEFORE any decode/enqueue — no VideoToolbox/Metal work
|
||||
// off-screen. Skips all recovery/gate bookkeeping too; exitBackground requests a
|
||||
// fresh IDR and the re-anchor gate re-arms on the resumed frame-index gap.
|
||||
if connection.isVideoDropped {
|
||||
_ = try connection.nextAU(timeoutMs: 100)
|
||||
return true
|
||||
}
|
||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import PunktfunkShared
|
||||
import SwiftUI
|
||||
import os
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import AVFoundation
|
||||
import GameController
|
||||
import PunktfunkCore
|
||||
import PunktfunkShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// The App-Group foundation shared by the app and its extensions (Widgets / Live Activity).
|
||||
//
|
||||
// PunktfunkShared is deliberately dependency-free: it links NEITHER PunktfunkKit (which drags in
|
||||
// the Rust staticlib + presentation layer) NOR any Apple UI framework. A widget process gets ~30 MB,
|
||||
// so everything an extension needs — the stored-host model + its JSON codec, the settings-key names,
|
||||
// the deep-link grammar, and (later) the Live Activity attributes — lives here and here only.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The one App-Group identifier, matched by `Config/*.entitlements`
|
||||
/// (`com.apple.security.application-groups`). Registered on the developer portal for both the app
|
||||
/// id (`io.unom.punktfunk`) and the widget extension id (`io.unom.punktfunk.widgets`).
|
||||
public enum AppGroup {
|
||||
public static let suiteName = "group.io.unom.punktfunk"
|
||||
|
||||
/// The shared defaults suite. Non-nil in a correctly-entitled process; falls back to
|
||||
/// `.standard` if the group is somehow unavailable (unsigned `swift run`, a misprovisioned
|
||||
/// build) so the app still functions single-process rather than crashing — the widget just
|
||||
/// won't see the same store there.
|
||||
public static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// The `punktfunk://` deep-link grammar — the single builder/parser shared by the widget (which
|
||||
// emits links via `widgetURL`/`Link`) and the app (`ContentView.onOpenURL`, which routes them into
|
||||
// the existing connect path). Keeping both sides on one type means the wire format can't drift.
|
||||
//
|
||||
// Grammar (v1):
|
||||
// punktfunk://connect/<host-uuid> — connect to a stored host
|
||||
// punktfunk://connect/<host-uuid>?launch=<GameEntry.id> — connect and ask the host to launch it
|
||||
//
|
||||
// `launch` carries a `GameEntry.id` (e.g. "steam:570"); it is percent-encoded on build and decoded
|
||||
// on parse, so ids with reserved characters survive the round trip.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum DeepLink: Equatable {
|
||||
/// Connect to a saved host; `launchID` is a `GameEntry.id` to launch on arrival, if any.
|
||||
case connect(host: UUID, launchID: String?)
|
||||
|
||||
public static let scheme = "punktfunk"
|
||||
|
||||
/// Build the canonical URL for a route. Non-optional: every route is representable.
|
||||
public var url: URL {
|
||||
switch self {
|
||||
case let .connect(host, launchID):
|
||||
var comps = URLComponents()
|
||||
comps.scheme = Self.scheme
|
||||
comps.host = "connect"
|
||||
comps.path = "/\(host.uuidString)"
|
||||
if let launchID, !launchID.isEmpty {
|
||||
comps.queryItems = [URLQueryItem(name: "launch", value: launchID)]
|
||||
}
|
||||
// URLComponents percent-encodes the query value; force-unwrap is safe for a URL we
|
||||
// fully control (scheme/host/path are all valid).
|
||||
return comps.url!
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an incoming URL, or nil if it isn't a recognized `punktfunk://` route. Tolerant of
|
||||
/// case in the scheme and of a trailing slash on the path.
|
||||
public init?(_ url: URL) {
|
||||
guard url.scheme?.lowercased() == Self.scheme else { return nil }
|
||||
guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
|
||||
|
||||
switch comps.host?.lowercased() {
|
||||
case "connect":
|
||||
// Path is "/<uuid>"; strip the leading slash and any trailing one.
|
||||
let raw = comps.path
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let host = UUID(uuidString: raw) else { return nil }
|
||||
let launch = comps.queryItems?
|
||||
.first(where: { $0.name == "launch" })?.value
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
self = .connect(host: host, launchID: launch)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-1
@@ -1,7 +1,8 @@
|
||||
// One source of truth for the client's UserDefaults / @AppStorage keys. A magic-string key
|
||||
// duplicated across a setting's writer (a Settings @AppStorage) and reader (e.g. a stream view
|
||||
// reading UserDefaults) splits silently on a typo — the setting just stops taking effect. These
|
||||
// live in PunktfunkKit because both the app and the kit's views read them.
|
||||
// live in the dependency-free PunktfunkShared module (re-exported by PunktfunkKit) because the app,
|
||||
// the kit's views, AND the widget extension all read them — the widget needs `DefaultsKey.hosts`.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -111,6 +112,16 @@ public enum DefaultsKey {
|
||||
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
|
||||
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
|
||||
public static let autoWake = "punktfunk.autoWake"
|
||||
/// iOS/iPadOS: keep a streaming session ALIVE when the app is backgrounded (audio background
|
||||
/// mode). Off by default (today's freeze-on-background is the default). When on, backgrounding a
|
||||
/// live session keeps audio playing and the QUIC/pump live while DROPPING video decode, and a
|
||||
/// bounded timer (`backgroundTimeoutMinutes`) auto-disconnects if the user doesn't return. Read
|
||||
/// by ContentView's scenePhase driver. Hidden on tvOS/macOS.
|
||||
public static let backgroundKeepAlive = "punktfunk.backgroundKeepAlive"
|
||||
/// iOS/iPadOS: minutes a backgrounded keep-alive session runs before auto-disconnecting (a
|
||||
/// battery/thermal/bandwidth backstop). Default 10; the UI offers 1/5/10/30. The auto-disconnect
|
||||
/// is non-deliberate (host linger kept), so a late return reconnects fast. Read on enterBackground.
|
||||
public static let backgroundTimeoutMinutes = "punktfunk.backgroundTimeoutMinutes"
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
@@ -120,4 +131,15 @@ extension Notification.Name {
|
||||
/// menus) — it exists so the menu item is honest whenever it CAN fire, and as the shortcut's
|
||||
/// discoverable menu-bar surface.
|
||||
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
|
||||
|
||||
/// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`,
|
||||
/// which runs in the app's process): the app tears the active session down deliberately
|
||||
/// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` —
|
||||
/// the intent lives in PunktfunkShared and can't reach the app's `SessionModel` directly.
|
||||
public static let punktfunkEndActiveSession = Notification.Name("io.unom.punktfunk.end-active-session")
|
||||
|
||||
/// Posted by the Connect App Intent (Siri/Shortcuts) with a `punktfunk://` URL as `object`:
|
||||
/// the app routes it through the SAME `.onOpenURL` handler a widget tap uses (one router, one
|
||||
/// set of guards). The intent uses `openAppWhenRun`, so the app is foregrounded to receive it.
|
||||
public static let punktfunkOpenDeepLink = Notification.Name("io.unom.punktfunk.open-deep-link")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// The saved-host as an App Intents entity — the parameter type for the Connect/Wake intents and
|
||||
// the configurable single-host widget. Lives in the shared module (not the app) because widget
|
||||
// *configuration* intents execute in the EXTENSION process, so the entity can't be app-only.
|
||||
//
|
||||
// AppIntents is genuinely available on macOS (13+), so this is gated on `canImport(AppIntents)`
|
||||
// (unlike ActivityKit, whose macOS types are unavailable) — it compiles on every platform and the
|
||||
// entity query reads the same shared App-Group store the widget does.
|
||||
|
||||
#if canImport(AppIntents)
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
public struct HostEntity: AppEntity, Identifiable {
|
||||
public static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Host")
|
||||
public static let defaultQuery = HostEntityQuery()
|
||||
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
|
||||
public init(id: UUID, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public init(_ host: StoredHost) {
|
||||
self.id = host.id
|
||||
self.name = host.displayName
|
||||
}
|
||||
|
||||
public var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: "\(name)")
|
||||
}
|
||||
}
|
||||
|
||||
public struct HostEntityQuery: EntityQuery {
|
||||
public init() {}
|
||||
|
||||
public func entities(for identifiers: [UUID]) async throws -> [HostEntity] {
|
||||
Self.loadHosts().filter { identifiers.contains($0.id) }.map(HostEntity.init)
|
||||
}
|
||||
|
||||
/// Sorted most-recent first — Siri/Shortcuts and the widget config picker suggest recent hosts.
|
||||
public func suggestedEntities() async throws -> [HostEntity] {
|
||||
Self.loadHosts().map(HostEntity.init)
|
||||
}
|
||||
|
||||
static func loadHosts() -> [StoredHost] {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return [] }
|
||||
return hosts.sorted {
|
||||
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,68 @@
|
||||
// The Live Activity's attributes — the ONE type that must be identical in the app (which starts
|
||||
// and updates the Activity) and the widget extension (which renders it). Hence it lives in the
|
||||
// dependency-free shared module.
|
||||
//
|
||||
// Gated on `os(iOS)`, NOT `canImport(ActivityKit)`: ActivityKit *imports* on macOS but its types
|
||||
// are `@available(macOS, unavailable)`, so canImport would wrongly admit this on the macOS build.
|
||||
// Live Activities are iPhone/iPad only (iPadOS reports os(iOS)).
|
||||
//
|
||||
// Naming/shape is a runtime contract: an Activity started by one build is decoded by the extension
|
||||
// of the same build, so keep `ContentState` Codable-stable across releases the way `StoredHost` is.
|
||||
|
||||
#if os(iOS)
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
public struct PunktfunkSessionAttributes: ActivityAttributes {
|
||||
// Static for the Activity's whole life (set at request time).
|
||||
public let hostID: UUID
|
||||
public let hostName: String
|
||||
/// The title of the launched game, if the session started from the library; nil for a plain
|
||||
/// host connect (nothing tracks the live foreground app mid-session).
|
||||
public let launchTitle: String?
|
||||
|
||||
public init(hostID: UUID, hostName: String, launchTitle: String?) {
|
||||
self.hostID = hostID
|
||||
self.hostName = hostName
|
||||
self.launchTitle = launchTitle
|
||||
}
|
||||
|
||||
public struct ContentState: Codable, Hashable {
|
||||
public enum Stage: String, Codable, Hashable {
|
||||
case streaming // foreground, live
|
||||
case background // backgrounded keep-alive (countdown running)
|
||||
case reconnecting // post-loss re-anchor hold
|
||||
case ending // torn down — final state before dismissal
|
||||
}
|
||||
|
||||
public var stage: Stage
|
||||
/// Session start — drives `Text(timerInterval:)` for a free client-side ticking clock (no
|
||||
/// per-second push needed).
|
||||
public var startedAt: Date
|
||||
/// e.g. "2560×1440 @120 · HEVC · HDR". Updated only when it actually changes.
|
||||
public var modeLine: String
|
||||
/// Coarse, updated sparsely (every ~30 s) — never the 1 Hz stats firehose.
|
||||
public var latencyMs: Int?
|
||||
public var mbps: Double?
|
||||
/// While backgrounded: when the keep-alive auto-disconnect fires — drives the countdown.
|
||||
public var backgroundDeadline: Date?
|
||||
|
||||
public init(
|
||||
stage: Stage, startedAt: Date, modeLine: String,
|
||||
latencyMs: Int? = nil, mbps: Double? = nil, backgroundDeadline: Date? = nil
|
||||
) {
|
||||
self.stage = stage
|
||||
self.startedAt = startedAt
|
||||
self.modeLine = modeLine
|
||||
self.latencyMs = latencyMs
|
||||
self.mbps = mbps
|
||||
self.backgroundDeadline = backgroundDeadline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind string for the Live Activity — kept next to the attributes so app + extension agree.
|
||||
public enum PunktfunkActivity {
|
||||
public static let kind = "PunktfunkSession"
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,30 @@
|
||||
// App Intents that must compile into BOTH the app and the widget extension live here in the shared
|
||||
// module. Today that's `EndStreamIntent` — the Live Activity's "End stream" button (a
|
||||
// LiveActivityIntent runs in the APP's process) which M4 also surfaces to Siri/Shortcuts.
|
||||
//
|
||||
// Gated on os(iOS): LiveActivityIntent is part of ActivityKit's world (iPhone/iPad only). The M4
|
||||
// Connect/Wake intents that need the app's router live in the app target, not here.
|
||||
|
||||
#if os(iOS)
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
/// Ends the active streaming session. Backs the Live Activity's End button and the Shortcuts /
|
||||
/// Siri "End the Punktfunk stream" phrase. `perform()` runs in the app's process (LiveActivityIntent)
|
||||
/// — it posts `.punktfunkEndActiveSession`, which the app's SessionModel owner observes and turns
|
||||
/// into `disconnect(deliberate: true)` (the user explicitly ended it → quit-close the host).
|
||||
@available(iOS 17.0, *)
|
||||
public struct EndStreamIntent: LiveActivityIntent {
|
||||
public static let title: LocalizedStringResource = "End Punktfunk Stream"
|
||||
public static let description = IntentDescription("Ends the active Punktfunk streaming session.")
|
||||
|
||||
public init() {}
|
||||
|
||||
public func perform() async throws -> some IntentResult {
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .punktfunkEndActiveSession, object: nil)
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,57 @@
|
||||
// The saved-host model + its on-disk JSON wire format — the widget/extension depends on BOTH, so
|
||||
// they live in the dependency-free shared module. The `ObservableObject` store that wraps them
|
||||
// (`HostStore`, with add/remove/pin/reachability) stays in the app target; discovery-join helpers
|
||||
// (`matches`, `advertises`) stay there too because they reference PunktfunkKit's `DiscoveredHost`.
|
||||
//
|
||||
// Wire-format stability: the JSON encoding of `StoredHost` is now a shared contract between the app
|
||||
// (writer) and the widget (reader). The `PunktfunkSharedTests` codec round-trip pins it — do not
|
||||
// rename the coding keys or make a stored `Optional` non-optional (older saved JSON must still
|
||||
// decode; synthesized Decodable treats a missing Optional as nil).
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The management-API port default (distinct from the data-plane `port`). Lives here (not in
|
||||
/// PunktfunkKit's LibraryClient, which re-exports it) so `StoredHost.effectiveMgmtPort` can resolve
|
||||
/// it without the shared module taking a dependency on the kit.
|
||||
public let punktfunkDefaultMgmtPort: UInt16 = 47990
|
||||
|
||||
public struct StoredHost: Identifiable, Codable, Hashable {
|
||||
public var id = UUID()
|
||||
public var name: String
|
||||
public var address: String
|
||||
public var port: UInt16 = 9777
|
||||
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
|
||||
public var pinnedSHA256: Data?
|
||||
/// Last time a streaming session actually started (nil until the first one).
|
||||
public var lastConnected: Date?
|
||||
/// Management-API port for the library browser (distinct from the data-plane `port`). Optional
|
||||
/// (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks this key — still
|
||||
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
||||
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
||||
public var mgmtPort: UInt16?
|
||||
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
|
||||
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
|
||||
/// client can send a magic packet to wake the host later (when it's asleep and no longer
|
||||
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
|
||||
public var macAddresses: [String]?
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777,
|
||||
pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil,
|
||||
macAddresses: [String]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.pinnedSHA256 = pinnedSHA256
|
||||
self.lastConnected = lastConnected
|
||||
self.mgmtPort = mgmtPort
|
||||
self.macAddresses = macAddresses
|
||||
}
|
||||
|
||||
public var displayName: String { name.isEmpty ? address : name }
|
||||
public var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
|
||||
public var wakeMacs: [String] { macAddresses ?? [] }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// PunktfunkShared is now a wire contract between the app (writer) and the widget extension +
|
||||
// deep-link senders (readers). These pin the two formats that cross that boundary:
|
||||
// • the `StoredHost` JSON codec — the widget decodes the exact bytes the app persisted, and
|
||||
// older saved JSON (missing `mgmtPort` / `macAddresses`) must still decode;
|
||||
// • the `punktfunk://` deep-link grammar — the widget builds URLs the app parses.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
import PunktfunkShared
|
||||
|
||||
final class SharedFoundationTests: XCTestCase {
|
||||
// MARK: - StoredHost JSON codec
|
||||
|
||||
func testStoredHostRoundTrips() throws {
|
||||
let host = StoredHost(
|
||||
id: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!,
|
||||
name: "Tower", address: "192.168.1.173", port: 9777,
|
||||
pinnedSHA256: Data([0xDE, 0xAD, 0xBE, 0xEF]),
|
||||
lastConnected: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"])
|
||||
|
||||
let data = try JSONEncoder().encode(host)
|
||||
let decoded = try JSONDecoder().decode(StoredHost.self, from: data)
|
||||
XCTAssertEqual(decoded, host)
|
||||
}
|
||||
|
||||
/// Older saved hosts predate `mgmtPort`/`macAddresses` — a missing key must decode to nil, not
|
||||
/// throw (synthesized Decodable treats a missing Optional as nil). This is the forward-compat
|
||||
/// guarantee the widget depends on when reading a store written by any prior build.
|
||||
func testStoredHostDecodesLegacyJSONWithoutOptionalKeys() throws {
|
||||
let json = """
|
||||
{"id":"11111111-2222-3333-4444-555555555555","name":"Old","address":"10.0.0.5","port":9777}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let decoded = try JSONDecoder().decode(StoredHost.self, from: json)
|
||||
XCTAssertEqual(decoded.name, "Old")
|
||||
XCTAssertNil(decoded.mgmtPort)
|
||||
XCTAssertNil(decoded.macAddresses)
|
||||
XCTAssertNil(decoded.pinnedSHA256)
|
||||
XCTAssertNil(decoded.lastConnected)
|
||||
// Resolvers fall back cleanly.
|
||||
XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort)
|
||||
XCTAssertEqual(decoded.wakeMacs, [])
|
||||
XCTAssertEqual(decoded.displayName, "Old")
|
||||
}
|
||||
|
||||
func testStoredHostDisplayNameFallsBackToAddress() {
|
||||
let host = StoredHost(name: "", address: "10.0.0.9")
|
||||
XCTAssertEqual(host.displayName, "10.0.0.9")
|
||||
}
|
||||
|
||||
// MARK: - DeepLink grammar
|
||||
|
||||
func testDeepLinkConnectRoundTrips() {
|
||||
let id = UUID()
|
||||
let link = DeepLink.connect(host: id, launchID: nil)
|
||||
let parsed = DeepLink(link.url)
|
||||
XCTAssertEqual(parsed, link)
|
||||
XCTAssertEqual(parsed, .connect(host: id, launchID: nil))
|
||||
}
|
||||
|
||||
func testDeepLinkConnectWithLaunchRoundTrips() {
|
||||
let id = UUID()
|
||||
// A store-qualified GameEntry.id with a reserved char must survive percent-encoding.
|
||||
let launch = "steam:570"
|
||||
let link = DeepLink.connect(host: id, launchID: launch)
|
||||
XCTAssertEqual(DeepLink(link.url), .connect(host: id, launchID: launch))
|
||||
}
|
||||
|
||||
func testDeepLinkParsesCanonicalString() throws {
|
||||
let id = UUID()
|
||||
let url = try XCTUnwrap(URL(string: "punktfunk://connect/\(id.uuidString)"))
|
||||
XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil))
|
||||
}
|
||||
|
||||
func testDeepLinkRejectsForeignSchemeAndBadHost() throws {
|
||||
let id = UUID()
|
||||
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "https://connect/\(id.uuidString)"))))
|
||||
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://connect/not-a-uuid"))))
|
||||
XCTAssertNil(DeepLink(try XCTUnwrap(URL(string: "punktfunk://bogus/\(id.uuidString)"))))
|
||||
}
|
||||
|
||||
func testDeepLinkSchemeIsCaseInsensitive() throws {
|
||||
let id = UUID()
|
||||
let url = try XCTUnwrap(URL(string: "PUNKTFUNK://connect/\(id.uuidString)"))
|
||||
XCTAssertEqual(DeepLink(url), .connect(host: id, launchID: nil))
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ impl AudioPlayer {
|
||||
.spawn(move || {
|
||||
if let Err(e) = render_thread(pcm_rx, recycle_tx, stop_t, ready_tx, channels as u8)
|
||||
{
|
||||
tracing::warn!(error = format!("{e:#}"), "audio playback thread ended");
|
||||
tracing::warn!(error = %format!("{e:#}"), "audio playback thread ended");
|
||||
}
|
||||
})
|
||||
.context("spawn audio thread")?;
|
||||
@@ -232,7 +232,7 @@ impl MicStreamer {
|
||||
.name("punktfunk-mic".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = mic_thread(&connector, stop_t) {
|
||||
tracing::warn!(error = format!("{e:#}"), "mic uplink thread ended");
|
||||
tracing::warn!(error = %format!("{e:#}"), "mic uplink thread ended");
|
||||
}
|
||||
})
|
||||
.context("spawn mic thread")?;
|
||||
|
||||
@@ -1493,7 +1493,7 @@ impl Worker {
|
||||
Err(e) => {
|
||||
tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed")
|
||||
}
|
||||
Ok(()) => tracing::debug!(pad = slot.index, low, high, "rumble: rendered"),
|
||||
Ok(()) => tracing::trace!(pad = slot.index, low, high, "rumble: rendered"),
|
||||
}
|
||||
slot.rumble.last = (low, high);
|
||||
slot.rumble.last_at = Some(Instant::now());
|
||||
|
||||
@@ -125,7 +125,7 @@ pub fn agent(
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| bad("tls config", &e))?
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }));
|
||||
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::new(pin)));
|
||||
let cert = rustls::pki_types::CertificateDer::from_pem_slice(identity.0.as_bytes())
|
||||
.map_err(|e| bad("client cert pem", &e))?;
|
||||
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(identity.1.as_bytes())
|
||||
@@ -251,71 +251,6 @@ fn classify(e: ureq::Error) -> LibraryError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint-pinning verifier — the client-HTTP twin of core's (private) QUIC
|
||||
/// `PinVerify`: trust is the SHA-256 of the host's self-signed leaf cert. The handshake
|
||||
/// signatures MUST still be verified for real: CertificateVerify is what proves the peer
|
||||
/// *holds the pinned cert's private key* — skip it and an active MITM can replay the
|
||||
/// host's (public) certificate, match the pin, and complete the handshake with its own key.
|
||||
#[derive(Debug)]
|
||||
struct PinVerify {
|
||||
pin: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
if let Some(expected) = self.pin {
|
||||
let fp = punktfunk_core::quic::endpoint::cert_fingerprint(end_entity.as_ref());
|
||||
if fp != expected {
|
||||
return Err(rustls::Error::InvalidCertificate(
|
||||
rustls::CertificateError::ApplicationVerificationFailure,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -222,7 +222,7 @@ fn pump(
|
||||
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"PUNKTFUNK_PREFER_PYROWAVE=1 but the presenter device failed the pyrowave probe — keeping the normal codec preference"
|
||||
"PUNKTFUNK_PREFER_PYROWAVE=1 but the presenter device failed the pyrowave probe — keeping the normal codec preference"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -791,7 +791,7 @@ fn spawn_audio(
|
||||
buf.extend_from_slice(&pcm[..n]);
|
||||
player.push(buf);
|
||||
}
|
||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||
Err(e) => tracing::debug!(error = %e, "opus decode failed"),
|
||||
}
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {}
|
||||
|
||||
@@ -510,7 +510,7 @@ impl Decoder {
|
||||
if choice == "vaapi" {
|
||||
return Err(e.context("PUNKTFUNK_DECODER=vaapi but VAAPI failed"));
|
||||
}
|
||||
tracing::info!(reason = %e, "VAAPI unavailable — software decode");
|
||||
tracing::warn!(error = %e, "VAAPI unavailable — falling back to software decode");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,8 +695,8 @@ impl Decoder {
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
self.vaapi_fails = 0;
|
||||
} else {
|
||||
tracing::warn!(error = %e,
|
||||
"{which} decode error — requesting keyframe, keeping hardware decode");
|
||||
tracing::debug!(backend = which, error = %e,
|
||||
"decode error — requesting keyframe, keeping hardware decode");
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
@@ -1653,7 +1653,7 @@ unsafe extern "C" fn pick_vulkan(
|
||||
&mut fr,
|
||||
);
|
||||
if r < 0 || fr.is_null() {
|
||||
tracing::warn!("avcodec_get_hw_frames_parameters(VULKAN) failed ({r})");
|
||||
tracing::warn!(code = r, "avcodec_get_hw_frames_parameters(VULKAN) failed");
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
}
|
||||
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
|
||||
@@ -1665,7 +1665,7 @@ unsafe extern "C" fn pick_vulkan(
|
||||
as _;
|
||||
let r = ffi::av_hwframe_ctx_init(fr);
|
||||
if r < 0 {
|
||||
tracing::warn!("av_hwframe_ctx_init(VULKAN) failed ({r})");
|
||||
tracing::warn!(code = r, "av_hwframe_ctx_init(VULKAN) failed");
|
||||
let mut fr = fr;
|
||||
ffi::av_buffer_unref(&mut fr);
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
|
||||
@@ -537,7 +537,8 @@ impl PyroWaveDecoder {
|
||||
let fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
|
||||
|
||||
tracing::info!(
|
||||
mode = %format!("{width}x{height}"),
|
||||
width,
|
||||
height,
|
||||
"PyroWave decoder open on the presenter's device (compute iDWT, BT.709 limited)"
|
||||
);
|
||||
Ok(PyroWaveDecoder {
|
||||
@@ -602,8 +603,8 @@ impl PyroWaveDecoder {
|
||||
});
|
||||
self.next = 0;
|
||||
tracing::info!(
|
||||
from = %format!("{}x{}", self.width, self.height),
|
||||
to = %format!("{width}x{height}"),
|
||||
from = %format_args!("{}x{}", self.width, self.height),
|
||||
to = %format_args!("{width}x{height}"),
|
||||
"PyroWave decoder rebuilt for mid-stream resize"
|
||||
);
|
||||
self.width = width;
|
||||
|
||||
@@ -101,7 +101,7 @@ pub fn import(
|
||||
// EGL could leave an INVALID modifier to the driver's implied choice; explicit-
|
||||
// modifier images can't — LINEAR is the only honest guess (debug-visible if wrong).
|
||||
let modifier = if frame.modifier == DRM_FORMAT_MOD_INVALID {
|
||||
tracing::debug!("dmabuf carried no explicit modifier — importing as LINEAR");
|
||||
tracing::trace!("dmabuf carried no explicit modifier — importing as LINEAR");
|
||||
DRM_FORMAT_MOD_LINEAR
|
||||
} else {
|
||||
frame.modifier
|
||||
|
||||
@@ -184,6 +184,11 @@ struct StreamState {
|
||||
// Hardware-path health: a failure streak (or a device with no import support at
|
||||
// all) demotes the decoder to software via the shared flag — once per session.
|
||||
dmabuf_demoted: bool,
|
||||
/// PyroWave present has no demote rung (nothing else decodes the codec), so a
|
||||
/// persistent non-device-lost present failure would warn on every frame. Latch it:
|
||||
/// warn on the first failure of a streak, then stay quiet until a present succeeds.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pyro_present_warned: bool,
|
||||
hw_fails: u32,
|
||||
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
|
||||
osd_text: String,
|
||||
@@ -255,6 +260,8 @@ impl StreamState {
|
||||
win_start: Instant::now(),
|
||||
presented: PresentedWindow::default(),
|
||||
dmabuf_demoted: false,
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pyro_present_warned: false,
|
||||
hw_fails: 0,
|
||||
osd_text: String::new(),
|
||||
last_stats: None,
|
||||
@@ -542,7 +549,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
fullscreen = !fullscreen;
|
||||
tracing::debug!(fullscreen, "fullscreen toggle");
|
||||
if let Err(e) = window.set_fullscreen(fullscreen) {
|
||||
tracing::warn!(error = %e, "fullscreen toggle");
|
||||
tracing::warn!(error = %e, fullscreen, "failed to toggle fullscreen");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -985,13 +992,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
FrameInput::PyroWave(f),
|
||||
overlay_frame.as_ref(),
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Ok(p) => {
|
||||
st.pyro_present_warned = false;
|
||||
p
|
||||
}
|
||||
Err(e) => {
|
||||
if device_lost(&e) {
|
||||
return Err(e)
|
||||
.context("GPU device lost — the session cannot continue");
|
||||
}
|
||||
tracing::warn!(error = %format!("{e:#}"), "pyrowave present failed");
|
||||
if !st.pyro_present_warned {
|
||||
st.pyro_present_warned = true;
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"pyrowave present failed — suppressing repeats until it recovers"
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,13 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Cert-fingerprint pinning shared across the clients (the one `PinVerify` in `tls.rs`). Light:
|
||||
# rustls + sha2 only, no QUIC runtime — so a lean consumer (the tray's loopback status poll) can
|
||||
# pin the host cert without pulling tokio/quinn. The heavier `quic` feature builds on top of it.
|
||||
tls = ["dep:rustls", "dep:sha2", "dep:rustls-pki-types"]
|
||||
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
|
||||
# never on the per-frame hot path. Off by default so the core stays runtime-free.
|
||||
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2", "dep:opus"]
|
||||
quic = ["tls", "dep:quinn", "dep:tokio", "dep:rcgen", "dep:hmac", "dep:spake2", "dep:opus"]
|
||||
|
||||
[dependencies]
|
||||
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
||||
|
||||
@@ -1941,7 +1941,11 @@ async fn worker_main(args: WorkerArgs) {
|
||||
ResyncStep::Idle => {}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("unknown control message — ignoring");
|
||||
tracing::warn!(
|
||||
tag = ?msg.first(),
|
||||
len = msg.len(),
|
||||
"unknown control message — ignoring"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ pub mod reanchor;
|
||||
pub mod reject;
|
||||
pub mod session;
|
||||
pub mod stats;
|
||||
#[cfg(feature = "tls")]
|
||||
pub mod tls;
|
||||
pub mod transport;
|
||||
pub mod wol;
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Shared QUIC endpoint construction (host + client) and transport tuning — keep-alive and idle
|
||||
//! timeout, plus the certificate-fingerprint helpers. The cert-pinning verifier itself is the
|
||||
//! one shared [`crate::tls::PinVerify`]; this module only wires it into the client endpoint.
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
|
||||
@@ -130,11 +133,10 @@ pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> {
|
||||
certs.first().map(|c| cert_fingerprint(c.as_ref()))
|
||||
}
|
||||
|
||||
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
|
||||
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(cert_der).into()
|
||||
}
|
||||
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. The implementation is
|
||||
/// [`crate::tls::cert_fingerprint`] (shared with the HTTP clients); re-exported here so callers
|
||||
/// reaching it as `quic::endpoint::cert_fingerprint` keep working.
|
||||
pub use crate::tls::cert_fingerprint;
|
||||
|
||||
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
|
||||
/// must match what the client's verifier computes from the DER on the wire).
|
||||
@@ -178,10 +180,10 @@ pub fn client_pinned_with_identity(
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let builder = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(PinVerify {
|
||||
.with_custom_certificate_verifier(Arc::new(crate::tls::PinVerify::with_observed(
|
||||
pin,
|
||||
observed: observed.clone(),
|
||||
}));
|
||||
observed.clone(),
|
||||
)));
|
||||
let mut rustls_cfg = match identity {
|
||||
None => builder.with_no_client_auth(),
|
||||
Some((cert_pem, key_pem)) => {
|
||||
@@ -232,9 +234,6 @@ pub mod anyhow_result {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf
|
||||
/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what
|
||||
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
|
||||
/// Server-side client-cert verifier: accept any (self-signed) client certificate but
|
||||
/// verify the handshake signature for real — possession of the presented cert's key is
|
||||
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
|
||||
@@ -294,69 +293,3 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PinVerify {
|
||||
pin: Option<[u8; 32]>,
|
||||
observed: Arc<Mutex<Option<[u8; 32]>>>,
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
let fp = cert_fingerprint(end_entity.as_ref());
|
||||
*self.observed.lock().unwrap() = Some(fp);
|
||||
if let Some(expected) = self.pin {
|
||||
if fp != expected {
|
||||
return Err(rustls::Error::InvalidCertificate(
|
||||
rustls::CertificateError::ApplicationVerificationFailure,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
// The handshake signatures MUST be verified for real even though we pin the cert:
|
||||
// CertificateVerify is what proves the peer *holds the pinned cert's private key* —
|
||||
// skip it and an active MITM can replay the host's (public) certificate, match the
|
||||
// pin, and complete the handshake with its own key.
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Length-prefixed framing for QUIC control-stream messages: a `u16` length header followed by the
|
||||
//! payload, bounded at 64 KiB (control messages are tiny).
|
||||
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
|
||||
pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> {
|
||||
let mut len = [0u8; 2];
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! SPAKE2 password-authenticated key exchange for pairing: derive a shared key from the PIN and
|
||||
//! confirm it against both certificate fingerprints.
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use hmac::{Hmac, Mac};
|
||||
use spake2::{Ed25519Group, Identity, Password, Spake2};
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Shared TLS trust primitives for the punktfunk clients: the certificate-fingerprint hash and
|
||||
//! the one canonical fingerprint-pinning [`ServerCertVerifier`](rustls::client::danger::ServerCertVerifier)
|
||||
//! (`PinVerify`). Trust across the whole system is the SHA-256 of the host's self-signed leaf cert
|
||||
//! (TOFU-pinned), not a CA chain — and this verifier is what the QUIC connect, the game-library
|
||||
//! HTTP client, and the tray status poll all share, instead of hand-rolling it three times on a
|
||||
//! trust boundary. Behind the light `tls` feature (rustls + sha2 only — no QUIC runtime), which
|
||||
//! the heavier `quic` feature pulls in.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. Re-exported as
|
||||
/// `crate::quic::endpoint::cert_fingerprint` for callers that already reach it there.
|
||||
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(cert_der).into()
|
||||
}
|
||||
|
||||
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf cert,
|
||||
/// not a CA chain.
|
||||
///
|
||||
/// - `pin = Some(sha256)` rejects any host whose leaf doesn't hash to `sha256`.
|
||||
/// - `pin = None` accepts any leaf (trust-on-first-use) — pair with [`with_observed`](Self::with_observed)
|
||||
/// to record what was seen so the embedder can persist it and pin it from then on.
|
||||
///
|
||||
/// The handshake signatures are ALWAYS verified for real even though the cert is pinned:
|
||||
/// `CertificateVerify` is what proves the peer *holds the pinned cert's private key* — skip it and
|
||||
/// an active MITM can replay the host's (public) certificate, match the pin, and complete the
|
||||
/// handshake with its own key.
|
||||
#[derive(Debug)]
|
||||
pub struct PinVerify {
|
||||
pin: Option<[u8; 32]>,
|
||||
observed: Option<Arc<Mutex<Option<[u8; 32]>>>>,
|
||||
}
|
||||
|
||||
impl PinVerify {
|
||||
/// A verifier that pins `pin` (or accepts any when `None`) without recording what it saw —
|
||||
/// the HTTP clients, which connect with a known pin or accept-any and never need to persist
|
||||
/// the observed fingerprint.
|
||||
pub fn new(pin: Option<[u8; 32]>) -> Self {
|
||||
Self {
|
||||
pin,
|
||||
observed: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`new`](Self::new) but also writes the observed leaf fingerprint into `slot` during
|
||||
/// the handshake, so a trust-on-first-use caller (the QUIC connect) can read it off the slot
|
||||
/// and pin it on the next connect.
|
||||
pub fn with_observed(pin: Option<[u8; 32]>, slot: Arc<Mutex<Option<[u8; 32]>>>) -> Self {
|
||||
Self {
|
||||
pin,
|
||||
observed: Some(slot),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
// Only hash the leaf when the result depends on it: a pin to check and/or a slot to
|
||||
// record into. Accept-any-without-recording (an un-pinned HTTP agent) skips it.
|
||||
if self.pin.is_some() || self.observed.is_some() {
|
||||
let fp = cert_fingerprint(end_entity.as_ref());
|
||||
if let Some(slot) = &self.observed {
|
||||
*slot.lock().unwrap() = Some(fp);
|
||||
}
|
||||
if let Some(expected) = self.pin {
|
||||
if fp != expected {
|
||||
return Err(rustls::Error::InvalidCertificate(
|
||||
rustls::CertificateError::ApplicationVerificationFailure,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustls::client::danger::ServerCertVerifier;
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
|
||||
/// Drive the pin check against `cert_bytes`. `verify_server_cert` only hashes the leaf, so
|
||||
/// arbitrary bytes stand in for a DER certificate here.
|
||||
fn verify(v: &PinVerify, cert_bytes: &[u8]) -> std::result::Result<(), rustls::Error> {
|
||||
let der = CertificateDer::from(cert_bytes.to_vec());
|
||||
let name = ServerName::try_from("punktfunk").unwrap();
|
||||
v.verify_server_cert(
|
||||
&der,
|
||||
&[],
|
||||
&name,
|
||||
&[],
|
||||
UnixTime::since_unix_epoch(std::time::Duration::ZERO),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_pin_accepts_and_mismatch_is_rejected() {
|
||||
let cert = b"the-host-leaf-cert";
|
||||
let good = cert_fingerprint(cert);
|
||||
assert!(verify(&PinVerify::new(Some(good)), cert).is_ok());
|
||||
|
||||
let mut wrong = good;
|
||||
wrong[0] ^= 0xff;
|
||||
match verify(&PinVerify::new(Some(wrong)), cert) {
|
||||
Err(rustls::Error::InvalidCertificate(
|
||||
rustls::CertificateError::ApplicationVerificationFailure,
|
||||
)) => {}
|
||||
other => panic!("a pin mismatch must be rejected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pin_accepts_any_and_records_the_observed_fingerprint() {
|
||||
let cert = b"whatever-the-host-presents";
|
||||
let slot = Arc::new(Mutex::new(None));
|
||||
let v = PinVerify::with_observed(None, slot.clone());
|
||||
assert!(verify(&v, cert).is_ok());
|
||||
assert_eq!(*slot.lock().unwrap(), Some(cert_fingerprint(cert)));
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
/// Sized for 1 Gbps+: at ~1.2 Gbps on the wire an 8 MB buffer is only ~49 ms of steady state, and a
|
||||
/// single multi-MB IDR keyframe (~4 MB ≈ 3300 packets) instantly fills most of it. 32 MB gives ~200 ms
|
||||
/// of headroom and absorbs a keyframe burst without EAGAIN/ENOBUFS drops. (Paced sending —
|
||||
/// `punktfunk1.rs::paced_submit` — spreads a big frame's overflow, so this buffer mostly absorbs the
|
||||
/// `native.rs::paced_submit` — spreads a big frame's overflow, so this buffer mostly absorbs the
|
||||
/// immediate microburst rather than a whole unpaced frame.)
|
||||
pub(crate) const TARGET_SOCKBUF: usize = 32 * 1024 * 1024;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! ([`Transport::recv_batch`], ≤128/syscall into a reused ring) on Linux AND Android (which is
|
||||
//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
|
||||
//! — the 1 Gbps+ syscall lever (~125k → a few-k syscalls/sec at line rate). The host additionally
|
||||
//! paces each frame's send across the frame interval (see `punktfunk1.rs::paced_submit`) so a real
|
||||
//! paces each frame's send across the frame interval (see `native.rs::paced_submit`) so a real
|
||||
//! NIC doesn't drop a line-rate burst. All three layer on this same [`Transport`] seam (scalar
|
||||
//! fallbacks for loopback and the remaining targets).
|
||||
|
||||
@@ -230,8 +230,7 @@ mod uso {
|
||||
STATE.store(if off { 2 } else { 1 }, Ordering::Relaxed);
|
||||
tracing::info!(
|
||||
enabled = !off,
|
||||
"Windows UDP Send Offload (USO): {} (the 1 Gbps+ send lever; PUNKTFUNK_GSO=0 disables)",
|
||||
if off { "off" } else { "on" }
|
||||
"Windows UDP Send Offload (USO) resolved (the 1 Gbps+ send lever; PUNKTFUNK_GSO=0 disables)"
|
||||
);
|
||||
!off
|
||||
}
|
||||
|
||||
@@ -217,6 +217,9 @@ windows = { version = "0.62", features = [
|
||||
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_Kernel",
|
||||
# CreateToolhelp32Snapshot/Process32*W — the conflicting-streaming-host process scan
|
||||
# (src/detect/windows.rs): is Sunshine/Apollo/... running alongside us?
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
] }
|
||||
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
|
||||
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
||||
|
||||
@@ -77,7 +77,7 @@ src/
|
||||
inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4)
|
||||
audio/ · audio.rs Opus out + virtual mic (PipeWire / WASAPI)
|
||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
||||
punktfunk1.rs the native punktfunk/1 host (QUIC control + native-thread UDP data plane)
|
||||
native.rs the native punktfunk/1 host (QUIC control + native-thread UDP data plane)
|
||||
mgmt.rs · native_pairing.rs · stats_recorder.rs management API, pairing, perf capture
|
||||
hdr.rs · library.rs HDR metadata; multi-store game library
|
||||
linux/ · windows/ platform-confined backends
|
||||
|
||||
@@ -106,220 +106,6 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
anyhow::bail!("virtual mic requires Linux + PipeWire or Windows + a virtual audio device")
|
||||
}
|
||||
|
||||
/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout.
|
||||
pub const MIC_CHANNELS: u32 = 2;
|
||||
/// Bound for the shared mic frame queue (drop-newest when full): the host-lifetime queue is
|
||||
/// shared across all concurrent sessions and must not grow without limit under a near-line-rate
|
||||
/// flood (security-review 2026-06-28 S6). 64 × 5–20 ms frames ≈ 0.3–1.3 s of slack.
|
||||
const MIC_QUEUE_CAP: usize = 64;
|
||||
|
||||
/// Tuning for [`MicPump`]'s open/reopen/flush behaviour — parameterized so the tests can run the
|
||||
/// real pump loop in milliseconds instead of seconds.
|
||||
#[derive(Clone, Copy)]
|
||||
struct PumpTuning {
|
||||
/// First-retry delay after a failed backend open; doubles per failure up to `backoff_cap`
|
||||
/// (a persistently-absent PipeWire session / audio endpoint isn't hammered), resets on
|
||||
/// success.
|
||||
backoff_start: std::time::Duration,
|
||||
backoff_cap: std::time::Duration,
|
||||
/// Idle liveness-probe interval: with no frames flowing, the pump still notices a dead
|
||||
/// backend this often and reopens — so the mic is healthy BEFORE the next session starts.
|
||||
heartbeat: std::time::Duration,
|
||||
/// An uplink gap longer than this discards the backend's buffered audio before pushing the
|
||||
/// next frame (a recorder must never hear a stale burst from before a mute/session end).
|
||||
stale_gap: std::time::Duration,
|
||||
/// A backend that dies before living this long counts as a FAILED open for backoff purposes
|
||||
/// (an open that succeeds but dies instantly — e.g. a flapping daemon — must not churn at
|
||||
/// heartbeat rate); one that lived longer resets the backoff.
|
||||
stable_after: std::time::Duration,
|
||||
}
|
||||
|
||||
const PUMP_TUNING: PumpTuning = PumpTuning {
|
||||
backoff_start: std::time::Duration::from_secs(2),
|
||||
backoff_cap: std::time::Duration::from_secs(60),
|
||||
heartbeat: std::time::Duration::from_secs(1),
|
||||
stale_gap: std::time::Duration::from_millis(600),
|
||||
stable_after: std::time::Duration::from_secs(5),
|
||||
};
|
||||
|
||||
/// Host-lifetime virtual-microphone pump: one thread owns the [`VirtualMic`] backend + an Opus
|
||||
/// decoder; sessions forward the client's Opus mic frames (0xCB) over a clonable `Send` sender,
|
||||
/// the thread decodes and feeds the backend.
|
||||
///
|
||||
/// The rock-solid properties live HERE, not in the backends:
|
||||
/// - **Eager**: the backend opens at host start (retrying with backoff), NOT on the first mic
|
||||
/// frame — so the virtual mic device already exists when host apps/games launch and bind
|
||||
/// their capture device (most games never re-follow a default-device change mid-run).
|
||||
/// - **Self-healing**: a dead backend (PipeWire restart, Windows endpoint churn) is detected on
|
||||
/// every push and on an idle heartbeat, and reopened with backoff. Sessions keep their
|
||||
/// senders; nothing upstream notices.
|
||||
/// - **Stale-flush**: buffered audio is discarded after an uplink gap (see [`PumpTuning`]).
|
||||
///
|
||||
/// Per-frame Opus DECODE errors stay non-fatal (dropped frame): the mic is shared across every
|
||||
/// concurrent session, so one paired client's junk frames must not deny everyone's mic
|
||||
/// (security-review 2026-06-28 S2). The thread exits when every sender is dropped (host
|
||||
/// shutdown), tearing the backend down.
|
||||
pub struct MicPump {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MicPump {
|
||||
/// Start the host-lifetime pump (Linux/Windows). On platforms without a virtual-mic backend
|
||||
/// the thread just drains and drops frames (sessions still count the datagrams).
|
||||
pub fn start() -> MicPump {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("punktfunk-mic-pump".into())
|
||||
.spawn(move || {
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pump_thread(rx, || open_virtual_mic(MIC_CHANNELS), PUMP_TUNING);
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
tracing::warn!("mic passthrough unsupported on this platform — frames dropped");
|
||||
for _ in rx {}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::error!(error = %e, "mic pump thread spawn failed — mic passthrough disabled");
|
||||
}
|
||||
MicPump { tx }
|
||||
}
|
||||
|
||||
/// A sender a session forwards the client's Opus mic frames to (`try_send` — never block a
|
||||
/// datagram loop). Cloned per session; dropping a clone does NOT stop the pump (it holds
|
||||
/// the original sender for the host life).
|
||||
pub fn sender(&self) -> std::sync::mpsc::SyncSender<Vec<u8>> {
|
||||
self.tx.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleep for `dur` while draining (and dropping) queued frames, so a closed/reopening backend
|
||||
/// never accumulates a stale backlog and senders never see a wedged queue. Returns `false` when
|
||||
/// every sender is gone (host shutdown).
|
||||
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
|
||||
fn drain_sleep(rx: &std::sync::mpsc::Receiver<Vec<u8>>, dur: std::time::Duration) -> bool {
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
let deadline = std::time::Instant::now() + dur;
|
||||
loop {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if left.is_zero() {
|
||||
return true;
|
||||
}
|
||||
match rx.recv_timeout(left.min(std::time::Duration::from_millis(250))) {
|
||||
Ok(_) => {} // drop frames while closed
|
||||
Err(RecvTimeoutError::Timeout) => {} // keep waiting
|
||||
Err(RecvTimeoutError::Disconnected) => return false, // host shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pump loop. `opener` is injected so the tests can run the REAL loop against a mock
|
||||
/// backend; production passes [`open_virtual_mic`].
|
||||
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
|
||||
fn pump_thread<O>(rx: std::sync::mpsc::Receiver<Vec<u8>>, opener: O, tuning: PumpTuning)
|
||||
where
|
||||
O: Fn() -> Result<Box<dyn VirtualMic>>,
|
||||
{
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
use std::time::Instant;
|
||||
|
||||
let mut backoff = tuning.backoff_start;
|
||||
let mut open_fails: u64 = 0;
|
||||
loop {
|
||||
// Open phase — eager, from thread start.
|
||||
let (mic, mut decoder) = loop {
|
||||
let opened = opener().and_then(|m| {
|
||||
let d = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo)
|
||||
.map_err(|e| anyhow::anyhow!("opus decoder: {e}"))?;
|
||||
Ok((m, d))
|
||||
});
|
||||
match opened {
|
||||
Ok(pair) => break pair,
|
||||
Err(e) => {
|
||||
// Throttle (1st, 2nd, 4th, 8th … failure): a box without a PipeWire session
|
||||
// or virtual audio device would otherwise log every backoff forever.
|
||||
open_fails += 1;
|
||||
if open_fails.is_power_of_two() {
|
||||
tracing::warn!(error = %format!("{e:#}"), attempts = open_fails,
|
||||
"virtual mic unavailable — retrying with backoff");
|
||||
}
|
||||
if !drain_sleep(&rx, backoff) {
|
||||
return;
|
||||
}
|
||||
backoff = (backoff * 2).min(tuning.backoff_cap);
|
||||
}
|
||||
}
|
||||
};
|
||||
tracing::info!("virtual mic ready (host-lifetime)");
|
||||
// Drop anything queued while (re)opening — it predates the backend. (The backoff does
|
||||
// NOT reset here: only an instance that proves stable resets it — see the death triage.)
|
||||
while rx.try_recv().is_ok() {}
|
||||
let opened_at = Instant::now();
|
||||
|
||||
// Pump phase — runs until the backend dies (break) or the host shuts down (return).
|
||||
let mut decode_fails: u64 = 0;
|
||||
let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch
|
||||
let mut last_push = Instant::now();
|
||||
loop {
|
||||
match rx.recv_timeout(tuning.heartbeat) {
|
||||
Ok(frame) => {
|
||||
if frame.is_empty() {
|
||||
continue; // DTX silence — the source underruns to silence on its own
|
||||
}
|
||||
if last_push.elapsed() > tuning.stale_gap {
|
||||
mic.discard();
|
||||
}
|
||||
match decoder.decode_float(&frame, &mut pcm, false) {
|
||||
Ok(samples_per_ch) => {
|
||||
let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len());
|
||||
if !mic.push(&pcm[..total]) {
|
||||
tracing::warn!("virtual mic backend died — reopening");
|
||||
break;
|
||||
}
|
||||
last_push = Instant::now();
|
||||
decode_fails = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
// Malformed/garbage frame: drop it, keep the shared mic + decoder
|
||||
// (see the struct docs). Throttled log (1, 2, 4, … fails).
|
||||
decode_fails += 1;
|
||||
if decode_fails.is_power_of_two() {
|
||||
tracing::warn!(error = %e, fails = decode_fails,
|
||||
"mic opus decode failed — dropping frame");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if !mic.alive() {
|
||||
tracing::warn!("virtual mic backend died while idle — reopening");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
tracing::debug!("mic pump stopped (host shutting down)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Death triage: an instance that lived is a one-off (PipeWire/audio-engine restart) —
|
||||
// reopen immediately with the backoff reset. One that died right after opening is a
|
||||
// failed open in disguise (flapping daemon, endpoint racing away): back off like the
|
||||
// open loop, or the pump would churn open→die→reopen at heartbeat rate.
|
||||
if opened_at.elapsed() >= tuning.stable_after {
|
||||
backoff = tuning.backoff_start;
|
||||
open_fails = 0;
|
||||
} else {
|
||||
open_fails += 1;
|
||||
if !drain_sleep(&rx, backoff) {
|
||||
return;
|
||||
}
|
||||
backoff = (backoff * 2).min(tuning.backoff_cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/audio_control.rs"]
|
||||
mod audio_control;
|
||||
@@ -335,211 +121,5 @@ mod wasapi_mic;
|
||||
#[path = "audio/wiring_plan.rs"]
|
||||
pub(crate) mod wiring_plan;
|
||||
|
||||
#[cfg(test)]
|
||||
mod pump_tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Mock backend: records pushes/discards, dies on command.
|
||||
struct MockMic {
|
||||
alive: Arc<AtomicBool>,
|
||||
pushed: Arc<AtomicUsize>,
|
||||
discards: Arc<AtomicUsize>,
|
||||
}
|
||||
impl VirtualMic for MockMic {
|
||||
fn push(&self, pcm: &[f32]) -> bool {
|
||||
if !self.alive.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
self.pushed.fetch_add(pcm.len(), Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
fn alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Acquire)
|
||||
}
|
||||
fn discard(&self) {
|
||||
self.discards.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
opens: Arc<AtomicUsize>,
|
||||
alive: Arc<Mutex<Option<Arc<AtomicBool>>>>, // latest instance's kill switch
|
||||
pushed: Arc<AtomicUsize>,
|
||||
discards: Arc<AtomicUsize>,
|
||||
join: std::thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Run the REAL pump loop against mock backends; `fail_first` opens fail before the first
|
||||
/// success (exercises the eager retry/backoff path). `dead_on_arrival` opens every instance
|
||||
/// pre-killed (exercises the rapid-death churn guard). `stable_after` mirrors the tuning
|
||||
/// field (ZERO = every death counts as stable → immediate reopen, keeping tests fast).
|
||||
fn start_tuned(fail_first: usize, dead_on_arrival: bool, stable_after: Duration) -> Harness {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
|
||||
let opens = Arc::new(AtomicUsize::new(0));
|
||||
let alive = Arc::new(Mutex::new(None::<Arc<AtomicBool>>));
|
||||
let pushed = Arc::new(AtomicUsize::new(0));
|
||||
let discards = Arc::new(AtomicUsize::new(0));
|
||||
let (opens2, alive2, pushed2, discards2) = (
|
||||
opens.clone(),
|
||||
alive.clone(),
|
||||
pushed.clone(),
|
||||
discards.clone(),
|
||||
);
|
||||
let tuning = PumpTuning {
|
||||
backoff_start: Duration::from_millis(10),
|
||||
backoff_cap: Duration::from_millis(40),
|
||||
heartbeat: Duration::from_millis(20),
|
||||
stale_gap: Duration::from_millis(80),
|
||||
stable_after,
|
||||
};
|
||||
let join = std::thread::spawn(move || {
|
||||
pump_thread(
|
||||
rx,
|
||||
move || {
|
||||
let n = opens2.fetch_add(1, Ordering::SeqCst);
|
||||
if n < fail_first {
|
||||
anyhow::bail!("backend not up yet (simulated)");
|
||||
}
|
||||
let a = Arc::new(AtomicBool::new(!dead_on_arrival));
|
||||
*alive2.lock().unwrap() = Some(a.clone());
|
||||
Ok(Box::new(MockMic {
|
||||
alive: a,
|
||||
pushed: pushed2.clone(),
|
||||
discards: discards2.clone(),
|
||||
}) as Box<dyn VirtualMic>)
|
||||
},
|
||||
tuning,
|
||||
)
|
||||
});
|
||||
Harness {
|
||||
tx,
|
||||
opens,
|
||||
alive,
|
||||
pushed,
|
||||
discards,
|
||||
join,
|
||||
}
|
||||
}
|
||||
|
||||
fn start(fail_first: usize) -> Harness {
|
||||
start_tuned(fail_first, false, Duration::ZERO)
|
||||
}
|
||||
|
||||
fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
|
||||
for _ in 0..200 {
|
||||
if cond() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
panic!("timed out waiting for: {what}");
|
||||
}
|
||||
|
||||
fn opus_frame() -> Vec<u8> {
|
||||
let mut enc = opus::Encoder::new(48_000, opus::Channels::Stereo, opus::Application::Voip)
|
||||
.expect("opus encoder");
|
||||
let pcm = [0.1f32; 960 * 2]; // 20 ms stereo
|
||||
let mut out = vec![0u8; 4000];
|
||||
let n = enc.encode_float(&pcm, &mut out).expect("encode");
|
||||
out.truncate(n);
|
||||
out
|
||||
}
|
||||
|
||||
/// Eager: the backend opens (after transient failures) with NO frame ever sent.
|
||||
#[test]
|
||||
fn opens_eagerly_with_backoff() {
|
||||
let h = start(3);
|
||||
wait_until("eager open after 3 failures", || {
|
||||
h.opens.load(Ordering::SeqCst) >= 4 && h.alive.lock().unwrap().is_some()
|
||||
});
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// Frames flow: opus in → PCM pushed to the backend.
|
||||
#[test]
|
||||
fn decodes_and_pushes() {
|
||||
let h = start(0);
|
||||
wait_until("open", || h.alive.lock().unwrap().is_some());
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("pcm pushed", || h.pushed.load(Ordering::SeqCst) > 0);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// A dead backend is noticed WHILE IDLE (heartbeat) and reopened without any traffic.
|
||||
#[test]
|
||||
fn reopens_after_idle_death() {
|
||||
let h = start(0);
|
||||
wait_until("first open", || h.opens.load(Ordering::SeqCst) >= 1);
|
||||
wait_until("instance", || h.alive.lock().unwrap().is_some());
|
||||
h.alive
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.store(false, Ordering::Release); // kill it
|
||||
wait_until("reopen after idle death", || {
|
||||
h.opens.load(Ordering::SeqCst) >= 2
|
||||
});
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// A death detected on push (frame flowing) also reopens, and the frame after reopen flows.
|
||||
#[test]
|
||||
fn reopens_after_push_death() {
|
||||
let h = start(0);
|
||||
wait_until("instance", || h.alive.lock().unwrap().is_some());
|
||||
h.alive
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.store(false, Ordering::Release);
|
||||
h.tx.send(opus_frame()).unwrap(); // push sees death → reopen
|
||||
wait_until("reopen", || h.opens.load(Ordering::SeqCst) >= 2);
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("pcm after reopen", || h.pushed.load(Ordering::SeqCst) > 0);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// Instances that die immediately after opening must be retried with BACKOFF, not at
|
||||
/// heartbeat rate — a flapping backend (daemon up but dropping us instantly) would
|
||||
/// otherwise churn open→die→reopen every heartbeat forever.
|
||||
#[test]
|
||||
fn rapid_death_backs_off() {
|
||||
// Every instance is dead on arrival; stability threshold high so each death counts
|
||||
// as a failed open. Without the guard: ~1 reopen per heartbeat (20 ms) ≈ 25 opens in
|
||||
// 500 ms. With backoff 10→20→40 (cap): ≈ 7.
|
||||
let h = start_tuned(0, true, Duration::from_secs(10));
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let opens = h.opens.load(Ordering::SeqCst);
|
||||
assert!(opens >= 2, "must keep retrying (got {opens})");
|
||||
assert!(
|
||||
opens <= 15,
|
||||
"must back off, not churn per heartbeat (got {opens})"
|
||||
);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// An uplink gap discards buffered-stale audio before the next frame plays.
|
||||
#[test]
|
||||
fn discards_after_gap() {
|
||||
let h = start(0);
|
||||
wait_until("instance", || h.alive.lock().unwrap().is_some());
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("first push", || h.pushed.load(Ordering::SeqCst) > 0);
|
||||
std::thread::sleep(Duration::from_millis(150)); // > stale_gap
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("discard on gap", || h.discards.load(Ordering::SeqCst) >= 1);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
}
|
||||
mod mic_pump;
|
||||
pub use mic_pump::MicPump;
|
||||
|
||||
@@ -156,7 +156,10 @@ impl PwMicSource {
|
||||
.name("punktfunk-pw-mic".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = mic_pw_thread(pcm_rx, quit_rx, channels, flush_t, ready_tx) {
|
||||
tracing::error!(error = %format!("{e:#}"), "pipewire virtual-mic thread failed");
|
||||
// Reaching here is always a setup/open failure (once the mainloop runs it exits
|
||||
// Ok) — and it was already reported to the pump via the ready handshake, which
|
||||
// owns the throttled operator-facing warn. Keep only a debug breadcrumb.
|
||||
tracing::debug!(error = %format!("{e:#}"), "pipewire virtual-mic setup failed — pump will back off and retry");
|
||||
}
|
||||
// Whether a clean quit or a daemon death: this instance is done — the pump reopens.
|
||||
alive_t.store(false, Ordering::Release);
|
||||
@@ -322,7 +325,7 @@ fn mic_pw_thread(
|
||||
.state_changed({
|
||||
let mainloop = mainloop.clone();
|
||||
move |_s, _ud, old, new| {
|
||||
tracing::info!(?old, ?new, "pipewire virtual-mic stream state");
|
||||
tracing::debug!(?old, ?new, "pipewire virtual-mic stream state");
|
||||
// A stream error is unrecoverable for this instance — exit so the pump reopens.
|
||||
if matches!(new, pw::stream::StreamState::Error(_)) {
|
||||
mainloop.quit();
|
||||
@@ -522,7 +525,7 @@ fn pw_thread(
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(tx)
|
||||
.state_changed(|_s, _ud, old, new| {
|
||||
tracing::info!(?old, ?new, "pipewire audio stream state");
|
||||
tracing::debug!(?old, ?new, "pipewire audio stream state");
|
||||
})
|
||||
.param_changed(|_stream, _tx, id, param| {
|
||||
let Some(param) = param else { return };
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Host-lifetime virtual-microphone pump: one thread owns the [`VirtualMic`] backend plus an Opus
|
||||
//! decoder, self-heals across backend deaths, and feeds decoded client-mic PCM into the source so
|
||||
//! the client's microphone reaches host apps. Split out of the `audio` facade (§2.1 — a
|
||||
//! self-contained stateful subsystem does not belong in the trait facade); the [`VirtualMic`]
|
||||
//! trait, its factory ([`open_virtual_mic`](super::open_virtual_mic)) and the audio-plane sample
|
||||
//! rate stay in `super`.
|
||||
|
||||
use super::{VirtualMic, SAMPLE_RATE};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout.
|
||||
pub const MIC_CHANNELS: u32 = 2;
|
||||
/// Bound for the shared mic frame queue (drop-newest when full): the host-lifetime queue is
|
||||
/// shared across all concurrent sessions and must not grow without limit under a near-line-rate
|
||||
/// flood (security-review 2026-06-28 S6). 64 × 5–20 ms frames ≈ 0.3–1.3 s of slack.
|
||||
const MIC_QUEUE_CAP: usize = 64;
|
||||
|
||||
/// Tuning for [`MicPump`]'s open/reopen/flush behaviour — parameterized so the tests can run the
|
||||
/// real pump loop in milliseconds instead of seconds.
|
||||
#[derive(Clone, Copy)]
|
||||
struct PumpTuning {
|
||||
/// First-retry delay after a failed backend open; doubles per failure up to `backoff_cap`
|
||||
/// (a persistently-absent PipeWire session / audio endpoint isn't hammered), resets on
|
||||
/// success.
|
||||
backoff_start: std::time::Duration,
|
||||
backoff_cap: std::time::Duration,
|
||||
/// Idle liveness-probe interval: with no frames flowing, the pump still notices a dead
|
||||
/// backend this often and reopens — so the mic is healthy BEFORE the next session starts.
|
||||
heartbeat: std::time::Duration,
|
||||
/// An uplink gap longer than this discards the backend's buffered audio before pushing the
|
||||
/// next frame (a recorder must never hear a stale burst from before a mute/session end).
|
||||
stale_gap: std::time::Duration,
|
||||
/// A backend that dies before living this long counts as a FAILED open for backoff purposes
|
||||
/// (an open that succeeds but dies instantly — e.g. a flapping daemon — must not churn at
|
||||
/// heartbeat rate); one that lived longer resets the backoff.
|
||||
stable_after: std::time::Duration,
|
||||
}
|
||||
|
||||
const PUMP_TUNING: PumpTuning = PumpTuning {
|
||||
backoff_start: std::time::Duration::from_secs(2),
|
||||
backoff_cap: std::time::Duration::from_secs(60),
|
||||
heartbeat: std::time::Duration::from_secs(1),
|
||||
stale_gap: std::time::Duration::from_millis(600),
|
||||
stable_after: std::time::Duration::from_secs(5),
|
||||
};
|
||||
|
||||
/// Host-lifetime virtual-microphone pump: one thread owns the [`VirtualMic`] backend + an Opus
|
||||
/// decoder; sessions forward the client's Opus mic frames (0xCB) over a clonable `Send` sender,
|
||||
/// the thread decodes and feeds the backend.
|
||||
///
|
||||
/// The rock-solid properties live HERE, not in the backends:
|
||||
/// - **Eager**: the backend opens at host start (retrying with backoff), NOT on the first mic
|
||||
/// frame — so the virtual mic device already exists when host apps/games launch and bind
|
||||
/// their capture device (most games never re-follow a default-device change mid-run).
|
||||
/// - **Self-healing**: a dead backend (PipeWire restart, Windows endpoint churn) is detected on
|
||||
/// every push and on an idle heartbeat, and reopened with backoff. Sessions keep their
|
||||
/// senders; nothing upstream notices.
|
||||
/// - **Stale-flush**: buffered audio is discarded after an uplink gap (see [`PumpTuning`]).
|
||||
///
|
||||
/// Per-frame Opus DECODE errors stay non-fatal (dropped frame): the mic is shared across every
|
||||
/// concurrent session, so one paired client's junk frames must not deny everyone's mic
|
||||
/// (security-review 2026-06-28 S2). The thread exits when every sender is dropped (host
|
||||
/// shutdown), tearing the backend down.
|
||||
pub struct MicPump {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MicPump {
|
||||
/// Start the host-lifetime pump (Linux/Windows). On platforms without a virtual-mic backend
|
||||
/// the thread just drains and drops frames (sessions still count the datagrams).
|
||||
pub fn start() -> MicPump {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("punktfunk-mic-pump".into())
|
||||
.spawn(move || {
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pump_thread(rx, || super::open_virtual_mic(MIC_CHANNELS), PUMP_TUNING);
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
tracing::warn!("mic passthrough unsupported on this platform — frames dropped");
|
||||
for _ in rx {}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::error!(error = %e, "mic pump thread spawn failed — mic passthrough disabled");
|
||||
}
|
||||
MicPump { tx }
|
||||
}
|
||||
|
||||
/// A sender a session forwards the client's Opus mic frames to (`try_send` — never block a
|
||||
/// datagram loop). Cloned per session; dropping a clone does NOT stop the pump (it holds
|
||||
/// the original sender for the host life).
|
||||
pub fn sender(&self) -> std::sync::mpsc::SyncSender<Vec<u8>> {
|
||||
self.tx.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleep for `dur` while draining (and dropping) queued frames, so a closed/reopening backend
|
||||
/// never accumulates a stale backlog and senders never see a wedged queue. Returns `false` when
|
||||
/// every sender is gone (host shutdown).
|
||||
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
|
||||
fn drain_sleep(rx: &std::sync::mpsc::Receiver<Vec<u8>>, dur: std::time::Duration) -> bool {
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
let deadline = std::time::Instant::now() + dur;
|
||||
loop {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if left.is_zero() {
|
||||
return true;
|
||||
}
|
||||
match rx.recv_timeout(left.min(std::time::Duration::from_millis(250))) {
|
||||
Ok(_) => {} // drop frames while closed
|
||||
Err(RecvTimeoutError::Timeout) => {} // keep waiting
|
||||
Err(RecvTimeoutError::Disconnected) => return false, // host shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pump loop. `opener` is injected so the tests can run the REAL loop against a mock
|
||||
/// backend; production passes [`open_virtual_mic`](super::open_virtual_mic).
|
||||
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
|
||||
fn pump_thread<O>(rx: std::sync::mpsc::Receiver<Vec<u8>>, opener: O, tuning: PumpTuning)
|
||||
where
|
||||
O: Fn() -> Result<Box<dyn VirtualMic>>,
|
||||
{
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
use std::time::Instant;
|
||||
|
||||
let mut backoff = tuning.backoff_start;
|
||||
let mut open_fails: u64 = 0;
|
||||
loop {
|
||||
// Open phase — eager, from thread start.
|
||||
let (mic, mut decoder) = loop {
|
||||
let opened = opener().and_then(|m| {
|
||||
let d = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo)
|
||||
.map_err(|e| anyhow::anyhow!("opus decoder: {e}"))?;
|
||||
Ok((m, d))
|
||||
});
|
||||
match opened {
|
||||
Ok(pair) => break pair,
|
||||
Err(e) => {
|
||||
// Throttle (1st, 2nd, 4th, 8th … failure): a box without a PipeWire session
|
||||
// or virtual audio device would otherwise log every backoff forever.
|
||||
open_fails += 1;
|
||||
if open_fails.is_power_of_two() {
|
||||
tracing::warn!(error = %format!("{e:#}"), attempts = open_fails,
|
||||
"virtual mic unavailable — retrying with backoff");
|
||||
}
|
||||
if !drain_sleep(&rx, backoff) {
|
||||
return;
|
||||
}
|
||||
backoff = (backoff * 2).min(tuning.backoff_cap);
|
||||
}
|
||||
}
|
||||
};
|
||||
tracing::info!("virtual mic ready (host-lifetime)");
|
||||
// Drop anything queued while (re)opening — it predates the backend. (The backoff does
|
||||
// NOT reset here: only an instance that proves stable resets it — see the death triage.)
|
||||
while rx.try_recv().is_ok() {}
|
||||
let opened_at = Instant::now();
|
||||
|
||||
// Pump phase — runs until the backend dies (break) or the host shuts down (return).
|
||||
let mut decode_fails: u64 = 0;
|
||||
let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch
|
||||
let mut last_push = Instant::now();
|
||||
loop {
|
||||
match rx.recv_timeout(tuning.heartbeat) {
|
||||
Ok(frame) => {
|
||||
if frame.is_empty() {
|
||||
continue; // DTX silence — the source underruns to silence on its own
|
||||
}
|
||||
if last_push.elapsed() > tuning.stale_gap {
|
||||
mic.discard();
|
||||
}
|
||||
match decoder.decode_float(&frame, &mut pcm, false) {
|
||||
Ok(samples_per_ch) => {
|
||||
let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len());
|
||||
if !mic.push(&pcm[..total]) {
|
||||
tracing::warn!("virtual mic backend died — reopening");
|
||||
break;
|
||||
}
|
||||
last_push = Instant::now();
|
||||
decode_fails = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
// Malformed/garbage frame: drop it, keep the shared mic + decoder
|
||||
// (see the struct docs). Throttled log (1, 2, 4, … fails).
|
||||
decode_fails += 1;
|
||||
if decode_fails.is_power_of_two() {
|
||||
tracing::warn!(error = %e, fails = decode_fails,
|
||||
"mic opus decode failed — dropping frame");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if !mic.alive() {
|
||||
tracing::warn!("virtual mic backend died while idle — reopening");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
tracing::debug!("mic pump stopped (host shutting down)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Death triage: an instance that lived is a one-off (PipeWire/audio-engine restart) —
|
||||
// reopen immediately with the backoff reset. One that died right after opening is a
|
||||
// failed open in disguise (flapping daemon, endpoint racing away): back off like the
|
||||
// open loop, or the pump would churn open→die→reopen at heartbeat rate.
|
||||
if opened_at.elapsed() >= tuning.stable_after {
|
||||
backoff = tuning.backoff_start;
|
||||
open_fails = 0;
|
||||
} else {
|
||||
open_fails += 1;
|
||||
if !drain_sleep(&rx, backoff) {
|
||||
return;
|
||||
}
|
||||
backoff = (backoff * 2).min(tuning.backoff_cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pump_tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Mock backend: records pushes/discards, dies on command.
|
||||
struct MockMic {
|
||||
alive: Arc<AtomicBool>,
|
||||
pushed: Arc<AtomicUsize>,
|
||||
discards: Arc<AtomicUsize>,
|
||||
}
|
||||
impl VirtualMic for MockMic {
|
||||
fn push(&self, pcm: &[f32]) -> bool {
|
||||
if !self.alive.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
self.pushed.fetch_add(pcm.len(), Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
fn alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Acquire)
|
||||
}
|
||||
fn discard(&self) {
|
||||
self.discards.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
opens: Arc<AtomicUsize>,
|
||||
alive: Arc<Mutex<Option<Arc<AtomicBool>>>>, // latest instance's kill switch
|
||||
pushed: Arc<AtomicUsize>,
|
||||
discards: Arc<AtomicUsize>,
|
||||
join: std::thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Run the REAL pump loop against mock backends; `fail_first` opens fail before the first
|
||||
/// success (exercises the eager retry/backoff path). `dead_on_arrival` opens every instance
|
||||
/// pre-killed (exercises the rapid-death churn guard). `stable_after` mirrors the tuning
|
||||
/// field (ZERO = every death counts as stable → immediate reopen, keeping tests fast).
|
||||
fn start_tuned(fail_first: usize, dead_on_arrival: bool, stable_after: Duration) -> Harness {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
|
||||
let opens = Arc::new(AtomicUsize::new(0));
|
||||
let alive = Arc::new(Mutex::new(None::<Arc<AtomicBool>>));
|
||||
let pushed = Arc::new(AtomicUsize::new(0));
|
||||
let discards = Arc::new(AtomicUsize::new(0));
|
||||
let (opens2, alive2, pushed2, discards2) = (
|
||||
opens.clone(),
|
||||
alive.clone(),
|
||||
pushed.clone(),
|
||||
discards.clone(),
|
||||
);
|
||||
let tuning = PumpTuning {
|
||||
backoff_start: Duration::from_millis(10),
|
||||
backoff_cap: Duration::from_millis(40),
|
||||
heartbeat: Duration::from_millis(20),
|
||||
stale_gap: Duration::from_millis(80),
|
||||
stable_after,
|
||||
};
|
||||
let join = std::thread::spawn(move || {
|
||||
pump_thread(
|
||||
rx,
|
||||
move || {
|
||||
let n = opens2.fetch_add(1, Ordering::SeqCst);
|
||||
if n < fail_first {
|
||||
anyhow::bail!("backend not up yet (simulated)");
|
||||
}
|
||||
let a = Arc::new(AtomicBool::new(!dead_on_arrival));
|
||||
*alive2.lock().unwrap() = Some(a.clone());
|
||||
Ok(Box::new(MockMic {
|
||||
alive: a,
|
||||
pushed: pushed2.clone(),
|
||||
discards: discards2.clone(),
|
||||
}) as Box<dyn VirtualMic>)
|
||||
},
|
||||
tuning,
|
||||
)
|
||||
});
|
||||
Harness {
|
||||
tx,
|
||||
opens,
|
||||
alive,
|
||||
pushed,
|
||||
discards,
|
||||
join,
|
||||
}
|
||||
}
|
||||
|
||||
fn start(fail_first: usize) -> Harness {
|
||||
start_tuned(fail_first, false, Duration::ZERO)
|
||||
}
|
||||
|
||||
fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
|
||||
for _ in 0..200 {
|
||||
if cond() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
panic!("timed out waiting for: {what}");
|
||||
}
|
||||
|
||||
fn opus_frame() -> Vec<u8> {
|
||||
let mut enc = opus::Encoder::new(48_000, opus::Channels::Stereo, opus::Application::Voip)
|
||||
.expect("opus encoder");
|
||||
let pcm = [0.1f32; 960 * 2]; // 20 ms stereo
|
||||
let mut out = vec![0u8; 4000];
|
||||
let n = enc.encode_float(&pcm, &mut out).expect("encode");
|
||||
out.truncate(n);
|
||||
out
|
||||
}
|
||||
|
||||
/// Eager: the backend opens (after transient failures) with NO frame ever sent.
|
||||
#[test]
|
||||
fn opens_eagerly_with_backoff() {
|
||||
let h = start(3);
|
||||
wait_until("eager open after 3 failures", || {
|
||||
h.opens.load(Ordering::SeqCst) >= 4 && h.alive.lock().unwrap().is_some()
|
||||
});
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// Frames flow: opus in → PCM pushed to the backend.
|
||||
#[test]
|
||||
fn decodes_and_pushes() {
|
||||
let h = start(0);
|
||||
wait_until("open", || h.alive.lock().unwrap().is_some());
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("pcm pushed", || h.pushed.load(Ordering::SeqCst) > 0);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// A dead backend is noticed WHILE IDLE (heartbeat) and reopened without any traffic.
|
||||
#[test]
|
||||
fn reopens_after_idle_death() {
|
||||
let h = start(0);
|
||||
wait_until("first open", || h.opens.load(Ordering::SeqCst) >= 1);
|
||||
wait_until("instance", || h.alive.lock().unwrap().is_some());
|
||||
h.alive
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.store(false, Ordering::Release); // kill it
|
||||
wait_until("reopen after idle death", || {
|
||||
h.opens.load(Ordering::SeqCst) >= 2
|
||||
});
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// A death detected on push (frame flowing) also reopens, and the frame after reopen flows.
|
||||
#[test]
|
||||
fn reopens_after_push_death() {
|
||||
let h = start(0);
|
||||
wait_until("instance", || h.alive.lock().unwrap().is_some());
|
||||
h.alive
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.store(false, Ordering::Release);
|
||||
h.tx.send(opus_frame()).unwrap(); // push sees death → reopen
|
||||
wait_until("reopen", || h.opens.load(Ordering::SeqCst) >= 2);
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("pcm after reopen", || h.pushed.load(Ordering::SeqCst) > 0);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// Instances that die immediately after opening must be retried with BACKOFF, not at
|
||||
/// heartbeat rate — a flapping backend (daemon up but dropping us instantly) would
|
||||
/// otherwise churn open→die→reopen every heartbeat forever.
|
||||
#[test]
|
||||
fn rapid_death_backs_off() {
|
||||
// Every instance is dead on arrival; stability threshold high so each death counts
|
||||
// as a failed open. Without the guard: ~1 reopen per heartbeat (20 ms) ≈ 25 opens in
|
||||
// 500 ms. With backoff 10→20→40 (cap): ≈ 7.
|
||||
let h = start_tuned(0, true, Duration::from_secs(10));
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let opens = h.opens.load(Ordering::SeqCst);
|
||||
assert!(opens >= 2, "must keep retrying (got {opens})");
|
||||
assert!(
|
||||
opens <= 15,
|
||||
"must back off, not churn per heartbeat (got {opens})"
|
||||
);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// An uplink gap discards buffered-stale audio before the next frame plays.
|
||||
#[test]
|
||||
fn discards_after_gap() {
|
||||
let h = start(0);
|
||||
wait_until("instance", || h.alive.lock().unwrap().is_some());
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("first push", || h.pushed.load(Ordering::SeqCst) > 0);
|
||||
std::thread::sleep(Duration::from_millis(150)); // > stale_gap
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("discard on gap", || h.discards.load(Ordering::SeqCst) >= 1);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ impl WasapiLoopbackCapturer {
|
||||
.name("punktfunk-wasapi-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
|
||||
tracing::error!(error = format!("{e:#}"), "wasapi loopback thread failed");
|
||||
tracing::error!(error = %format!("{e:#}"), "wasapi loopback thread failed");
|
||||
}
|
||||
})
|
||||
.context("spawn wasapi audio thread")?;
|
||||
|
||||
@@ -650,8 +650,6 @@ mod pipewire {
|
||||
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
||||
/// so the GPU encoder re-uploads its cursor texture only on change.
|
||||
serial: u64,
|
||||
/// One-shot guard for the "cursor present but this frame is zero-copy" notice.
|
||||
warned_zerocopy: bool,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
@@ -1174,22 +1172,6 @@ mod pipewire {
|
||||
if ud.broken.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Cursor-as-metadata only reaches the frame on the CPU de-pad path below (a small
|
||||
// straight-alpha blit). The zero-copy paths hand a GPU-resident buffer straight to the
|
||||
// encoder, so the cached cursor can't be composited here — that needs a GPU blit in the
|
||||
// encoder (follow-up). Note it once, so a gamescope host (zero-copy by default) shows in
|
||||
// the logs that the metadata IS arriving even while the overlay isn't drawn yet.
|
||||
if ud.cursor.visible
|
||||
&& !ud.cursor.warned_zerocopy
|
||||
&& (ud.importer.is_some() || ud.vaapi_passthrough)
|
||||
{
|
||||
ud.cursor.warned_zerocopy = true;
|
||||
tracing::warn!(
|
||||
"cursor metadata received, but frames are delivered zero-copy (GPU-resident) — \
|
||||
the cursor overlay is composited only on the CPU capture path today; GPU-path \
|
||||
compositing (Vulkan/CUDA/VAAPI encode) is a follow-up"
|
||||
);
|
||||
}
|
||||
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
|
||||
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
|
||||
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
|
||||
@@ -1241,7 +1223,7 @@ mod pipewire {
|
||||
std::sync::atomic::AtomicBool::new(true);
|
||||
if F2.swap(false, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
error = %format!("{e}"),
|
||||
error = %e,
|
||||
"dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \
|
||||
zero-copy may show stale frames (no producer explicit sync)"
|
||||
);
|
||||
@@ -1915,7 +1897,15 @@ mod pipewire {
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
}));
|
||||
if outcome.is_err() {
|
||||
tracing::error!("panic in pipewire process callback — frame dropped");
|
||||
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
|
||||
// format) would fire this every frame, so power-of-two throttle it — enough to
|
||||
// surface the fault without evicting the whole log ring.
|
||||
static PANICS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
let n = PANICS.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if n.is_power_of_two() {
|
||||
tracing::error!(count = n, "panic in pipewire process callback — frame dropped");
|
||||
}
|
||||
}
|
||||
})
|
||||
.register()
|
||||
@@ -1930,7 +1920,11 @@ mod pipewire {
|
||||
|
||||
// Request raw video in any encoder-mappable layout, any size/framerate.
|
||||
let obj = if let Some((fw, fh)) = fixed_pod {
|
||||
tracing::info!(fw, fh, "PW DEBUG: offering fixed BGRx pod");
|
||||
tracing::info!(
|
||||
fw,
|
||||
fh,
|
||||
"pipewire: offering a fixed BGRx format pod (PUNKTFUNK_PW_FIXED_POD)"
|
||||
);
|
||||
pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
|
||||
@@ -299,7 +299,7 @@ pub(crate) fn install_gpu_pref_hook() {
|
||||
// 100% DuplicateOutput1 E_ACCESSDENIED is diagnosable instead of silent.
|
||||
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
|
||||
Ok(()) => tracing::info!("DPI awareness set: PER_MONITOR_AWARE_V2"),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:?}"),
|
||||
Err(e) => tracing::warn!(error = ?e,
|
||||
"SetProcessDpiAwarenessContext failed (already set?) — DuplicateOutput1 may E_ACCESSDENIED"),
|
||||
}
|
||||
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). DuplicateOutput1 needs 2.
|
||||
|
||||
@@ -193,10 +193,20 @@ impl Drop for KeyedMutexGuard<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Nudge DWM into composing THE TARGET virtual display. DWM presents a display only when something
|
||||
/// DIRTIES it — an idle desktop never does, so a freshly-attached ring (session open, or a
|
||||
/// mid-session ring recreate) can sit at E_PENDING with no first frame even though everything is
|
||||
/// healthy. pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
|
||||
/// LAST-RESORT fallback: nudge DWM into composing THE TARGET virtual display. DWM presents a
|
||||
/// display only when something DIRTIES it — an idle desktop never does, so a freshly-attached ring
|
||||
/// (session open, or a mid-session ring recreate) can sit at E_PENDING with no first frame even
|
||||
/// though everything is healthy.
|
||||
///
|
||||
/// The PRIMARY first-frame mechanism is the driver's `FrameStash` (frame_transport.rs): the driver
|
||||
/// retains the last composed frame and republishes it into every freshly-attached ring, so with a
|
||||
/// stash-capable driver the first frame lands milliseconds after the channel delivery and this kick
|
||||
/// never fires. It remains for pre-stash drivers and for the empty-stash cold start (a monitor that
|
||||
/// has NEVER composed — normally the activation compose covers that). Synthetic input is inherently
|
||||
/// unreliable — blocked on the secure desktop, defeated by a fullscreen game's ClipCursor, and
|
||||
/// user-visible in the sibling-display case — which is exactly why it was demoted to fallback.
|
||||
///
|
||||
/// pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
|
||||
/// the frame — a guaranteed real present onto the IDD swap-chain (empirically what
|
||||
/// `punktfunk-probe --input-test` always relied on).
|
||||
///
|
||||
@@ -595,7 +605,7 @@ impl DescriptorPoller {
|
||||
.map_err(|e| {
|
||||
// Degraded, not fatal: the session streams, it just never follows a mid-session
|
||||
// HDR flip / mode-set (seq stays 0 → the consumer sees no changes).
|
||||
tracing::error!(error = %e, "IDD push: descriptor-poller thread failed to spawn");
|
||||
tracing::warn!(error = %e, "IDD push: descriptor-poller thread failed to spawn — mid-session HDR/mode changes won't be followed");
|
||||
})
|
||||
.ok();
|
||||
Self { snap, stop, thread }
|
||||
@@ -753,6 +763,13 @@ pub struct IddPushCapturer {
|
||||
/// during active flow and warns when they turn metronomic — the sole-virtual-display
|
||||
/// periodic-stutter diagnostic.
|
||||
stall_watch: StallWatch,
|
||||
/// Stall↔OS-event correlation counters for the metronomic warn: how many stalls this session,
|
||||
/// and how many had a coinciding [`crate::display_events`] event in their gap window — the
|
||||
/// discriminator between "Windows re-enumerates a monitor each cycle" (devnode churn the
|
||||
/// `pnp_disable_monitors` axis suppresses) and "the disturbance is below the OS" (GPU driver
|
||||
/// servicing a standby sink / display-poller software).
|
||||
stalls_seen: u32,
|
||||
stalls_with_os_events: u32,
|
||||
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
|
||||
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
||||
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
|
||||
@@ -886,6 +903,9 @@ impl IddPushCapturer {
|
||||
want_444: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
||||
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
||||
crate::display_events::spawn_once();
|
||||
match Self::open_inner(target, preferred, client_10bit, want_444) {
|
||||
Ok(mut me) => {
|
||||
me._keepalive = keepalive;
|
||||
@@ -1015,6 +1035,19 @@ impl IddPushCapturer {
|
||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||
let display_hdr = enabled_hdr
|
||||
|| crate::win_display::advanced_color_enabled(target.target_id).unwrap_or(false);
|
||||
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
||||
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
||||
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||
// BT.709, so the client's label overstates the stream until the descriptor poller sees
|
||||
// HDR come on. Loud, because every frame of this session is affected.
|
||||
if client_10bit && !display_hdr {
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
"IDD push: 10-bit HDR was negotiated but enabling advanced color on the \
|
||||
virtual display FAILED — encoding 8-bit SDR while the client was told HDR \
|
||||
(check the display driver / Windows HDR support on this box)"
|
||||
);
|
||||
}
|
||||
let ring_fmt = if display_hdr {
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT
|
||||
} else {
|
||||
@@ -1146,6 +1179,8 @@ impl IddPushCapturer {
|
||||
last_liveness: Instant::now(),
|
||||
last_kick: Instant::now(),
|
||||
stall_watch: StallWatch::new(),
|
||||
stalls_seen: 0,
|
||||
stalls_with_os_events: 0,
|
||||
out_ring: Vec::new(),
|
||||
out_idx: 0,
|
||||
video_conv: None,
|
||||
@@ -1173,9 +1208,12 @@ impl IddPushCapturer {
|
||||
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
|
||||
/// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard
|
||||
/// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past
|
||||
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom). At
|
||||
/// session open the OS activates the virtual display → DWM composites it → a frame arrives within ~1 s,
|
||||
/// so this does not false-fail a normal (even idle) open; no frame within the window = genuinely broken.
|
||||
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom).
|
||||
/// A stash-capable driver republishes its retained desktop frame the moment it attaches (the
|
||||
/// first-frame guarantee — `FrameStash`, driver frame_transport.rs), so the normal case clears this
|
||||
/// gate in milliseconds even on an idle desktop; failing that, at session open the OS activates the
|
||||
/// virtual display → DWM composites it → a frame arrives within ~1 s, plus the compose-kick fallback
|
||||
/// below — no frame within the window = genuinely broken.
|
||||
fn wait_for_attach(&self) -> Result<()> {
|
||||
// Symmetric host-side binding sanity (proto v3 §3.2): OUR header must still name OUR
|
||||
// monitor. The stamp is ours and nothing legitimate rewrites it, so a mismatch means a
|
||||
@@ -1192,11 +1230,15 @@ impl IddPushCapturer {
|
||||
);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
// Compose-kick schedule: DWM only presents a display something DIRTIED, so on an idle
|
||||
// desktop a perfectly healthy attach sees no first frame (E_PENDING forever) and this gate
|
||||
// used to fail the session — the "idle desktop → no frames" gotcha (a real client escaped
|
||||
// it only because its own input soon dirtied the desktop; a headless probe never did).
|
||||
// Give the natural post-activate compose a moment, then nudge.
|
||||
// First-frame expectation: a stash-capable driver republishes its retained desktop frame
|
||||
// the moment it attaches (`FrameStash`, frame_transport.rs), so on a healthy pairing the
|
||||
// gate below clears in milliseconds even on a perfectly idle desktop. The compose-kick
|
||||
// schedule is the FALLBACK for pre-stash drivers / an empty stash (a display that has
|
||||
// never composed): DWM only presents a display something DIRTIED, so on an idle desktop
|
||||
// an attach would otherwise sit at E_PENDING forever and fail this gate — the
|
||||
// "idle desktop → no frames" gotcha. Give the natural post-activate compose (and the
|
||||
// stash republish) a moment, then nudge; log when we do, so field logs show whether the
|
||||
// stash path is working.
|
||||
let mut next_kick = Instant::now() + Duration::from_millis(600);
|
||||
loop {
|
||||
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
|
||||
@@ -1249,6 +1291,14 @@ impl IddPushCapturer {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= next_kick {
|
||||
// Reaching a kick at all means the driver did NOT republish a retained frame
|
||||
// (pre-stash driver, or a never-composed display) — worth a line in the field log.
|
||||
tracing::debug!(
|
||||
target_id = self.target_id,
|
||||
driver_status = st,
|
||||
"IDD push: no first frame after attach delivery — falling back to a synthetic \
|
||||
compose kick (stash-capable drivers republish instantly; old driver?)"
|
||||
);
|
||||
kick_dwm_compose(self.target_id);
|
||||
next_kick = Instant::now() + Duration::from_millis(800);
|
||||
}
|
||||
@@ -1546,12 +1596,19 @@ impl IddPushCapturer {
|
||||
}
|
||||
// Same idle-desktop stall as the open-time attach gate: after a mid-session ring
|
||||
// recreate (HDR flip / mode change) an idle desktop composes nothing, so the fresh ring
|
||||
// never sees a frame and the 3 s recover-or-drop above kills a healthy session. Nudge
|
||||
// DWM (rate-limited) once the natural post-recreate compose has had its chance.
|
||||
// never sees a frame and the 3 s recover-or-drop above kills a healthy session. A
|
||||
// stash-capable driver republishes its retained frame at the re-attach, so this kick
|
||||
// is the legacy-driver fallback here too. Nudge DWM (rate-limited) once the natural
|
||||
// post-recreate compose (and the stash republish) has had its chance.
|
||||
if since.elapsed() > Duration::from_millis(600)
|
||||
&& self.last_kick.elapsed() > Duration::from_millis(800)
|
||||
{
|
||||
self.last_kick = Instant::now();
|
||||
tracing::debug!(
|
||||
target_id = self.target_id,
|
||||
"IDD push: no frame after ring recreate — falling back to a synthetic compose \
|
||||
kick (stash-capable drivers republish at re-attach; old driver?)"
|
||||
);
|
||||
kick_dwm_compose(self.target_id);
|
||||
}
|
||||
}
|
||||
@@ -1646,24 +1703,70 @@ impl IddPushCapturer {
|
||||
// doesn't read as a DWM stall.
|
||||
self.stall_watch.reset();
|
||||
} else if let Some(stall) = self.stall_watch.note_fresh(now) {
|
||||
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
|
||||
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
|
||||
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
|
||||
let window = stall.gap + Duration::from_millis(300);
|
||||
let events = now
|
||||
.checked_sub(window)
|
||||
.map(|from| crate::display_events::events_between(from, now))
|
||||
.unwrap_or_default();
|
||||
self.stalls_seen = self.stalls_seen.saturating_add(1);
|
||||
if !events.is_empty() {
|
||||
self.stalls_with_os_events = self.stalls_with_os_events.saturating_add(1);
|
||||
}
|
||||
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||
// at debug level, and the web-console debug ring captures these.
|
||||
tracing::debug!(
|
||||
gap_ms = stall.gap.as_millis() as u64,
|
||||
os_display_events = %crate::display_events::summarize(&events),
|
||||
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||
delivered no frame for the gap; the present path stalled below capture"
|
||||
);
|
||||
if let Some(period) = stall.metronomic {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
"capture stalls are METRONOMIC — DWM stops composing the virtual display \
|
||||
on a stable period, i.e. a periodic display-path disturbance BELOW \
|
||||
capture (DWM present clock / GPU driver / display-poller software). \
|
||||
Correlate with 'slow display-descriptor poll'; if that never fires, the \
|
||||
disturbance is outside punktfunk — try display topology=primary or \
|
||||
extend (keep a physical output active), or a different refresh rate"
|
||||
);
|
||||
let suspects = crate::display_events::connected_inactive_externals();
|
||||
let suspects = if suspects.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
suspects.join(", ")
|
||||
};
|
||||
let correlated = format!("{}/{}", self.stalls_with_os_events, self.stalls_seen);
|
||||
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||||
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||||
// driver. Different classes, different cures — say which one this box has.
|
||||
if self.stalls_with_os_events * 2 >= self.stalls_seen {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
"capture stalls are METRONOMIC and coincide with Windows monitor \
|
||||
hot-plug/re-enumeration events — a connected display (or its \
|
||||
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
|
||||
each time. Cures, best-first: that display's OSD 'auto input \
|
||||
scan/detect' OFF (and on TVs: instant-on/quick-start + CEC off), \
|
||||
unplug its cable at the GPU, an HPD-holding adapter/dummy plug, or \
|
||||
keep it active while streaming; the pnp_disable_monitors policy axis \
|
||||
suppresses the Windows-side reaction (see connected_inactive for the \
|
||||
suspects)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||
clock (try a different refresh rate). If connected_inactive lists a \
|
||||
display, its standby probing is the prime suspect: unplug it at the \
|
||||
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
|
||||
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
|
||||
streaming"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_fresh = now; // feeds the driver-death watch
|
||||
|
||||
@@ -49,7 +49,12 @@ pub struct HostConfig {
|
||||
/// `PUNKTFUNK_ZEROCOPY` — Windows D3D11 zero-copy encode input override. `None` (unset) defers to
|
||||
/// the per-vendor default (AMF on, QSV off — see module docs and `encode/ffmpeg_win.rs`).
|
||||
pub zerocopy: Option<bool>,
|
||||
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
||||
/// `PUNKTFUNK_10BIT` — host policy gate for 10-bit encode (HEVC Main10 / AV1 10-bit).
|
||||
/// **Default ON** (since 10-bit went probe-gated end-to-end, 2026-07-16): the host merely
|
||||
/// *allows* 10-bit — a session only becomes 10-bit when the client advertised `VIDEO_CAP_10BIT`
|
||||
/// (behind its HDR setting + display-capability gate), the codec supports it (HEVC/AV1), and
|
||||
/// the GPU/backend passed the encode probe (`can_encode_10bit`) — otherwise 8-bit SDR.
|
||||
/// `PUNKTFUNK_10BIT=0`/`false`/`off`/`no` disables. Independent of `four_four_four` (depth vs chroma).
|
||||
pub ten_bit: bool,
|
||||
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions).
|
||||
/// **Default ON** (since the pipeline went zero-copy + honest end-to-end, 2026-07-10): the
|
||||
@@ -108,7 +113,16 @@ impl HostConfig {
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
}),
|
||||
ten_bit: flag("PUNKTFUNK_10BIT"),
|
||||
// Default ON, explicit-off grammar (mirrors `four_four_four`: the client's HDR setting
|
||||
// is the real per-session switch; the encode probe keeps incapable GPUs honest at 8-bit).
|
||||
ten_bit: val("PUNKTFUNK_10BIT")
|
||||
.map(|s| {
|
||||
!matches!(
|
||||
s.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true),
|
||||
// Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF —
|
||||
// is the real switch; see the field doc).
|
||||
four_four_four: val("PUNKTFUNK_444")
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Conflicting game-streaming host detection.
|
||||
//!
|
||||
//! Punktfunk is one of a family of Moonlight-compatible desktop-streaming hosts. The others —
|
||||
//! Sunshine and its many forks (Apollo, Vibeshine, Vibepollo, LuminalShine, …) — all impersonate
|
||||
//! NVIDIA GameStream: they bind the **same** ports (47984/47989 nvhttp, 47998-48010 stream,
|
||||
//! 47990 web UI — which is also our management API), advertise the **same** `_nvstream._tcp`
|
||||
//! mDNS service, and frequently install a **conflicting virtual-display driver**. Running one of
|
||||
//! them alongside Punktfunk is unsupported — the symptoms are `address already in use` bind
|
||||
//! failures, pairing that silently fails, and capture/virtual-display glitches.
|
||||
//!
|
||||
//! This module proactively detects such a host (installed and/or running) so we can surface it as
|
||||
//! early as possible: at host startup (a `warn!` into the log ring + tray/console summary) and via
|
||||
//! the `detect-conflicts` subcommand the installers/support run.
|
||||
//!
|
||||
//! Detection is **fingerprint-first by name**: a small table of the known products (extend
|
||||
//! [`KNOWN`] as new forks appear) matched against running processes, registered OS services/units,
|
||||
//! and on-disk install markers. The platform back-ends (`detect/windows.rs`, `detect/linux.rs`)
|
||||
//! provide the raw facts; the matching + rendering here is portable and unit-tested.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "detect/windows.rs"]
|
||||
mod platform;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "detect/linux.rs"]
|
||||
mod platform;
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
mod platform {
|
||||
//! The host only runs on Windows/Linux; the crate still compiles on macOS (dev) — nothing to
|
||||
//! scan there.
|
||||
pub fn running_processes() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
pub fn static_evidence(_known: &super::Known) -> Vec<super::Evidence> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A known competing GameStream/Moonlight host.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Product {
|
||||
Sunshine,
|
||||
Apollo,
|
||||
Vibeshine,
|
||||
Vibepollo,
|
||||
Luminalshine,
|
||||
}
|
||||
|
||||
impl Product {
|
||||
/// The name shown to the user.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Product::Sunshine => "Sunshine",
|
||||
Product::Apollo => "Apollo",
|
||||
Product::Vibeshine => "Vibeshine",
|
||||
Product::Vibepollo => "Vibepollo",
|
||||
Product::Luminalshine => "LuminalShine",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a conflicting host was observed on this machine.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Evidence {
|
||||
/// A matching process is running **right now** (process/executable basename).
|
||||
Running { process: String },
|
||||
/// An OS service / systemd unit for the product is registered (installed; may be stopped).
|
||||
Service { name: String },
|
||||
/// Installed on disk — a Program Files directory, a flatpak app id, or a binary on `PATH`.
|
||||
Installed { at: String },
|
||||
}
|
||||
|
||||
impl Evidence {
|
||||
fn render(&self) -> String {
|
||||
match self {
|
||||
Evidence::Running { process } => format!("running now ({process})"),
|
||||
Evidence::Service { name } => format!("service {name}"),
|
||||
Evidence::Installed { at } => format!("installed at {at}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A detected conflicting host, with every piece of corroborating evidence found.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Detection {
|
||||
pub product: Product,
|
||||
pub evidence: Vec<Evidence>,
|
||||
}
|
||||
|
||||
impl Detection {
|
||||
/// True when a matching process is live — the acute case (a guaranteed resource clash the
|
||||
/// moment Punktfunk tries to bind its ports).
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.evidence
|
||||
.iter()
|
||||
.any(|e| matches!(e, Evidence::Running { .. }))
|
||||
}
|
||||
|
||||
/// A compact one-line label for the tray/console summary, e.g. `Sunshine (running)`.
|
||||
pub fn label(&self) -> String {
|
||||
if self.is_running() {
|
||||
format!("{} (running)", self.product.label())
|
||||
} else {
|
||||
self.product.label().to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the known-conflicting-host table. Names are matched case-insensitively; process /
|
||||
/// binary basenames are given **without** an extension (the platform code lowercases + strips
|
||||
/// `.exe`). Extend this as new Sunshine forks appear — the runtime, the subcommand, and the
|
||||
/// tray/console summary all key off this one list.
|
||||
pub struct Known {
|
||||
pub product: Product,
|
||||
/// Process / executable basenames (lowercase, no extension) that identify this host.
|
||||
pub processes: &'static [&'static str],
|
||||
/// Windows service names (SCM keys under `HKLM\SYSTEM\CurrentControlSet\Services`).
|
||||
pub win_services: &'static [&'static str],
|
||||
/// Windows install-dir basenames under `%ProgramFiles%` / `%ProgramFiles(x86)%`.
|
||||
pub win_dirs: &'static [&'static str],
|
||||
/// Linux systemd unit basenames (without `.service`), checked in the standard unit dirs.
|
||||
pub linux_units: &'static [&'static str],
|
||||
/// Linux flatpak application ids.
|
||||
pub flatpaks: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// The known Moonlight-compatible hosts that clash with Punktfunk. All are Sunshine or Sunshine
|
||||
/// forks; add new forks here (one row) and every surface picks them up.
|
||||
pub const KNOWN: &[Known] = &[
|
||||
Known {
|
||||
product: Product::Sunshine,
|
||||
processes: &["sunshine"],
|
||||
win_services: &["SunshineService"],
|
||||
win_dirs: &["Sunshine"],
|
||||
linux_units: &["sunshine"],
|
||||
flatpaks: &["dev.lizardbyte.app.Sunshine"],
|
||||
},
|
||||
Known {
|
||||
product: Product::Apollo,
|
||||
processes: &["apollo"],
|
||||
win_services: &["ApolloService"],
|
||||
win_dirs: &["Apollo"],
|
||||
linux_units: &["apollo"],
|
||||
flatpaks: &["dev.lizardbyte.app.Apollo"],
|
||||
},
|
||||
Known {
|
||||
product: Product::Vibeshine,
|
||||
processes: &["vibeshine"],
|
||||
win_services: &["VibeshineService"],
|
||||
win_dirs: &["Vibeshine"],
|
||||
linux_units: &["vibeshine"],
|
||||
flatpaks: &[],
|
||||
},
|
||||
Known {
|
||||
product: Product::Vibepollo,
|
||||
processes: &["vibepollo"],
|
||||
win_services: &["VibepolloService"],
|
||||
win_dirs: &["Vibepollo"],
|
||||
linux_units: &["vibepollo"],
|
||||
flatpaks: &[],
|
||||
},
|
||||
Known {
|
||||
product: Product::Luminalshine,
|
||||
processes: &["luminalshine"],
|
||||
win_services: &["LuminalShineService"],
|
||||
win_dirs: &["LuminalShine"],
|
||||
linux_units: &["luminalshine"],
|
||||
flatpaks: &[],
|
||||
},
|
||||
];
|
||||
|
||||
/// Why running side-by-side breaks — shared by every surface (log, subcommand, installers).
|
||||
pub const UNSUPPORTED_BLURB: &str =
|
||||
"Running Punktfunk alongside another Moonlight-compatible host \
|
||||
(Sunshine and its forks) is UNSUPPORTED: they bind the same GameStream ports (47984/47989, \
|
||||
47998-48010), advertise the same _nvstream mDNS name, and often install a conflicting \
|
||||
virtual-display driver. Expect \"address already in use\" errors, failed pairing, and capture \
|
||||
glitches. Stop and uninstall the other host, or don't run them at the same time.";
|
||||
|
||||
/// Scan the machine for conflicting hosts. Portable; dispatches into the platform back-end. Does
|
||||
/// real OS work (process enumeration, service/registry queries, filesystem stats) — cheap, but not
|
||||
/// free, so prefer the cached [`init`]/[`snapshot`] for hot paths.
|
||||
pub fn scan() -> Vec<Detection> {
|
||||
let procs = platform::running_processes();
|
||||
let mut out = Vec::new();
|
||||
for known in KNOWN {
|
||||
let mut evidence: Vec<Evidence> = Vec::new();
|
||||
for p in &procs {
|
||||
if known.processes.iter().any(|n| p == n) {
|
||||
evidence.push(Evidence::Running { process: p.clone() });
|
||||
}
|
||||
}
|
||||
evidence.extend(platform::static_evidence(known));
|
||||
if !evidence.is_empty() {
|
||||
out.push(Detection {
|
||||
product: known.product,
|
||||
evidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
static SNAPSHOT: OnceLock<Vec<Detection>> = OnceLock::new();
|
||||
|
||||
/// Scan once and cache the result for the life of the process (the conflict set doesn't change at
|
||||
/// streaming granularity — a snapshot taken at host bring-up is the right resolution and keeps the
|
||||
/// per-poll `/local/summary` free). Returns the cached detections.
|
||||
pub fn init() -> &'static [Detection] {
|
||||
SNAPSHOT.get_or_init(scan)
|
||||
}
|
||||
|
||||
/// The cached snapshot, or empty if [`init`] hasn't run. Non-scanning: safe to call from hot paths
|
||||
/// and from tests without touching the OS.
|
||||
pub fn snapshot() -> &'static [Detection] {
|
||||
SNAPSHOT.get().map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Compact labels for the tray / web-console summary (e.g. `["Sunshine (running)", "Apollo"]`).
|
||||
pub fn summary_labels(detections: &[Detection]) -> Vec<String> {
|
||||
detections.iter().map(Detection::label).collect()
|
||||
}
|
||||
|
||||
/// A full human-readable report: the blurb + one bullet per detected host with its evidence.
|
||||
/// Empty string when nothing was detected (callers gate on `is_empty()`).
|
||||
pub fn render_report(detections: &[Detection]) -> String {
|
||||
if detections.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut s = String::from("Detected another game-streaming host on this machine.\n");
|
||||
s.push_str(UNSUPPORTED_BLURB);
|
||||
s.push_str("\n\nDetected:\n");
|
||||
for d in detections {
|
||||
let ev = d
|
||||
.evidence
|
||||
.iter()
|
||||
.map(Evidence::render)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
s.push_str(&format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label()));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn det(product: Product, evidence: Vec<Evidence>) -> Detection {
|
||||
Detection { product, evidence }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_report_and_labels() {
|
||||
assert!(render_report(&[]).is_empty());
|
||||
assert!(summary_labels(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_detection_is_flagged_and_labelled() {
|
||||
let d = det(
|
||||
Product::Sunshine,
|
||||
vec![
|
||||
Evidence::Running {
|
||||
process: "sunshine".into(),
|
||||
},
|
||||
Evidence::Service {
|
||||
name: "SunshineService".into(),
|
||||
},
|
||||
],
|
||||
);
|
||||
assert!(d.is_running());
|
||||
assert_eq!(d.label(), "Sunshine (running)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_only_is_not_running() {
|
||||
let d = det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed {
|
||||
at: "C:\\Program Files\\Apollo".into(),
|
||||
}],
|
||||
);
|
||||
assert!(!d.is_running());
|
||||
assert_eq!(d.label(), "Apollo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_lists_every_product_and_the_blurb() {
|
||||
let report = render_report(&[
|
||||
det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Running {
|
||||
process: "sunshine".into(),
|
||||
}],
|
||||
),
|
||||
det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed {
|
||||
at: "/usr/bin/apollo".into(),
|
||||
}],
|
||||
),
|
||||
]);
|
||||
assert!(report.contains("UNSUPPORTED"));
|
||||
assert!(report.contains("Sunshine \u{2014} running now (sunshine)"));
|
||||
assert!(report.contains("Apollo \u{2014} installed at /usr/bin/apollo"));
|
||||
assert_eq!(
|
||||
summary_labels(&[
|
||||
det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Running {
|
||||
process: "sunshine".into()
|
||||
}]
|
||||
),
|
||||
det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed { at: "x".into() }]
|
||||
),
|
||||
]),
|
||||
vec!["Sunshine (running)".to_string(), "Apollo".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_table_rows_are_well_formed() {
|
||||
// Every known product carries at least a process name and a Windows service so the runtime
|
||||
// scan and the installer's registry check stay in agreement.
|
||||
for k in KNOWN {
|
||||
assert!(
|
||||
!k.processes.is_empty(),
|
||||
"{:?} has no process name",
|
||||
k.product
|
||||
);
|
||||
assert!(
|
||||
!k.win_services.is_empty(),
|
||||
"{:?} has no Windows service name",
|
||||
k.product
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Linux conflicting-host facts: `/proc` for running processes, the standard systemd unit dirs +
|
||||
//! flatpak app dirs + `PATH` for install markers. All best-effort and dependency-free (no
|
||||
//! subprocess spawns) — a missing `/proc` or an unreadable dir simply yields no evidence.
|
||||
|
||||
use super::{Evidence, Known};
|
||||
use std::path::Path;
|
||||
|
||||
/// Lowercased basenames of every process whose `/proc/<pid>/comm` we can read. `comm` is the
|
||||
/// kernel's 15-char command name — every host we match on (sunshine, apollo, vibeshine, vibepollo,
|
||||
/// luminalshine) fits within that, so no `/proc/<pid>/exe` readlink is needed.
|
||||
pub fn running_processes() -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||
return out;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
// Only numeric (pid) directories.
|
||||
if !entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(|n| n.bytes().all(|b| b.is_ascii_digit()))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) {
|
||||
out.push(comm.trim().to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// systemd unit registration + flatpak + `PATH` binary presence — the "installed" evidence.
|
||||
pub fn static_evidence(known: &Known) -> Vec<Evidence> {
|
||||
let mut ev = Vec::new();
|
||||
|
||||
// systemd units, system + per-user, in the dirs systemd actually reads.
|
||||
let home = std::env::var_os("HOME");
|
||||
let mut unit_dirs: Vec<String> = vec![
|
||||
"/etc/systemd/system".into(),
|
||||
"/run/systemd/system".into(),
|
||||
"/usr/lib/systemd/system".into(),
|
||||
"/lib/systemd/system".into(),
|
||||
"/etc/systemd/user".into(),
|
||||
"/usr/lib/systemd/user".into(),
|
||||
];
|
||||
if let Some(h) = &home {
|
||||
unit_dirs.push(format!("{}/.config/systemd/user", h.to_string_lossy()));
|
||||
}
|
||||
for unit in known.linux_units {
|
||||
let file = format!("{unit}.service");
|
||||
if unit_dirs.iter().any(|d| Path::new(d).join(&file).exists()) {
|
||||
ev.push(Evidence::Service { name: file });
|
||||
}
|
||||
}
|
||||
|
||||
// flatpak app installs (system + per-user).
|
||||
let mut flatpak_roots: Vec<String> = vec!["/var/lib/flatpak/app".into()];
|
||||
if let Some(h) = &home {
|
||||
flatpak_roots.push(format!("{}/.local/share/flatpak/app", h.to_string_lossy()));
|
||||
}
|
||||
for id in known.flatpaks {
|
||||
if flatpak_roots.iter().any(|r| Path::new(r).join(id).exists()) {
|
||||
ev.push(Evidence::Installed {
|
||||
at: format!("flatpak {id}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A matching binary on PATH (covers manual / package installs the unit/flatpak checks miss).
|
||||
let path = std::env::var_os("PATH");
|
||||
for bin in known.processes {
|
||||
if let Some(found) = find_on_path(bin, path.as_deref()) {
|
||||
ev.push(Evidence::Installed { at: found });
|
||||
}
|
||||
}
|
||||
|
||||
ev
|
||||
}
|
||||
|
||||
fn find_on_path(bin: &str, path: Option<&std::ffi::OsStr>) -> Option<String> {
|
||||
let dirs = path.map(std::env::split_paths).into_iter().flatten();
|
||||
// Always also probe the common bindirs, even if PATH is unset/narrow (e.g. a service context).
|
||||
let extra = ["/usr/bin", "/usr/local/bin", "/bin", "/usr/games"]
|
||||
.into_iter()
|
||||
.map(std::path::PathBuf::from);
|
||||
for dir in dirs.chain(extra) {
|
||||
let cand = dir.join(bin);
|
||||
if cand.is_file() {
|
||||
return Some(cand.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Windows conflicting-host facts: a Toolhelp process snapshot for what's running, the SCM for
|
||||
//! registered services, and `%ProgramFiles%` for on-disk installs. All best-effort — any failing
|
||||
//! query (no privilege, API error) yields no evidence rather than aborting startup.
|
||||
|
||||
use super::{Evidence, Known};
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_service::service::ServiceAccess;
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
|
||||
/// Lowercased executable basenames (without `.exe`) of every running process, via a Toolhelp
|
||||
/// snapshot. `szExeFile` is the module base name (e.g. `sunshine.exe`), not a full path.
|
||||
pub fn running_processes() -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: standard Toolhelp snapshot walk. The snapshot handle is closed on every exit path;
|
||||
// `entry` is fully initialized (dwSize set) before Process32FirstW reads it.
|
||||
unsafe {
|
||||
let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
|
||||
return out;
|
||||
};
|
||||
// Zeroed then dwSize set — the canonical Toolhelp init (no reliance on a Default impl for
|
||||
// the 260-wide szExeFile array).
|
||||
let mut entry = PROCESSENTRY32W {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
|
||||
..std::mem::zeroed()
|
||||
};
|
||||
if Process32FirstW(snap, &mut entry).is_ok() {
|
||||
loop {
|
||||
let end = entry
|
||||
.szExeFile
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(entry.szExeFile.len());
|
||||
let name = String::from_utf16_lossy(&entry.szExeFile[..end]).to_ascii_lowercase();
|
||||
out.push(name.strip_suffix(".exe").unwrap_or(&name).to_string());
|
||||
if Process32NextW(snap, &mut entry).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = CloseHandle(snap);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// SCM service registration + `%ProgramFiles%` install dirs — the "installed" evidence.
|
||||
pub fn static_evidence(known: &Known) -> Vec<Evidence> {
|
||||
let mut ev = Vec::new();
|
||||
for svc in known.win_services {
|
||||
if service_exists(svc) {
|
||||
ev.push(Evidence::Service {
|
||||
name: (*svc).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for dir in known.win_dirs {
|
||||
if let Some(at) = program_files_dir(dir) {
|
||||
ev.push(Evidence::Installed { at });
|
||||
}
|
||||
}
|
||||
ev
|
||||
}
|
||||
|
||||
/// True if a service by this name is registered with the SCM (running or stopped). Opening it with
|
||||
/// `QUERY_STATUS` fails cleanly when it doesn't exist.
|
||||
fn service_exists(name: &str) -> bool {
|
||||
let Ok(mgr) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
mgr.open_service(name, ServiceAccess::QUERY_STATUS).is_ok()
|
||||
}
|
||||
|
||||
/// The install directory under any of the Program Files roots, if it exists.
|
||||
fn program_files_dir(dir: &str) -> Option<String> {
|
||||
for var in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
|
||||
if let Some(base) = std::env::var_os(var) {
|
||||
let p = std::path::Path::new(&base).join(dir);
|
||||
if p.is_dir() {
|
||||
return Some(p.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Standalone dev/test subcommands that validate a subsystem without a streaming client: the
|
||||
//! input-injection smoke test and the virtual-gamepad exercisers (Linux UHID DualSense /
|
||||
//! Switch Pro; Windows UMDF DualSense-family + the Steam Deck devnode spike). Split out of the
|
||||
//! `main` CLI dispatch (plan §W5 "devtest.rs, land first") so `main.rs`'s match keeps only thin
|
||||
//! arms that forward here. Each fn owns the full behaviour (and doc) of its former inline arm.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Inject a scripted mouse + keyboard pattern through the session's input backend (libei on
|
||||
/// KWin/GNOME, wlr on Sway). Lets us validate input injection without a Moonlight client.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn input_test() -> Result<()> {
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::time::Duration;
|
||||
|
||||
let backend = crate::inject::default_backend();
|
||||
tracing::info!(?backend, "input-test: opening injector");
|
||||
let mut inj = crate::inject::open(backend)?;
|
||||
// An async backend (libei) needs a moment to establish its portal/EIS session + device
|
||||
// resume; events injected before then are dropped.
|
||||
std::thread::sleep(Duration::from_secs(4));
|
||||
|
||||
let ev = |kind, code, x, y| InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x,
|
||||
y,
|
||||
flags: 0,
|
||||
};
|
||||
tracing::info!(
|
||||
"input-test: injecting a mouse square + 'A'/click taps for ~8s (watch wev / focused app)"
|
||||
);
|
||||
for i in 0..160u32 {
|
||||
let (dx, dy) = match (i / 10) % 4 {
|
||||
0 => (12, 0),
|
||||
1 => (0, 12),
|
||||
2 => (-12, 0),
|
||||
_ => (0, -12),
|
||||
};
|
||||
if let Err(e) = inj.inject(&ev(InputKind::MouseMove, 0, dx, dy)) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "input-test: inject failed");
|
||||
}
|
||||
if i % 20 == 0 {
|
||||
let _ = inj.inject(&ev(InputKind::KeyDown, 0x41, 0, 0)); // 'A'
|
||||
let _ = inj.inject(&ev(InputKind::KeyUp, 0x41, 0, 0));
|
||||
let _ = inj.inject(&ev(InputKind::MouseButtonDown, 1, 0, 0)); // left click
|
||||
let _ = inj.inject(&ev(InputKind::MouseButtonUp, 1, 0, 0));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
tracing::info!("input-test: done");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn input_test() -> Result<()> {
|
||||
anyhow::bail!("input-test requires Linux")
|
||||
}
|
||||
|
||||
/// Create a virtual DualSense via UHID and exercise it (validation, no streaming session):
|
||||
/// toggles the Cross button, sweeps the left stick, and prints any HID output the kernel
|
||||
/// sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`.
|
||||
/// `--edge` creates a DualSense **Edge** (054C:0DF2) instead and additionally cycles the
|
||||
/// four back/Fn buttons (kernel ≥ 7.2 exposes them as BTN_TRIGGER_HAPPY1..4; on older
|
||||
/// kernels verify the bind + `hidraw` byte 10 instead).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn dualsense_test(args: &[String]) -> Result<()> {
|
||||
use crate::inject::dualsense::{DsUhidIdentity, DualSensePad};
|
||||
use crate::inject::dualsense_proto::{edge_paddle_bits, DsState};
|
||||
let secs: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--seconds")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20);
|
||||
let edge = args.iter().any(|a| a == "--edge");
|
||||
let (identity, label) = if edge {
|
||||
(DsUhidIdentity::dualsense_edge(), "DualSense Edge")
|
||||
} else {
|
||||
(DsUhidIdentity::dualsense(), "DualSense")
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
let mut pad = DualSensePad::open(0, &identity)
|
||||
.with_context(|| format!("create virtual {label} via /dev/uhid"))?;
|
||||
// Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input
|
||||
// devices before we start streaming state.
|
||||
let init = Instant::now() + Duration::from_millis(800);
|
||||
while Instant::now() < init {
|
||||
pad.service(0);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
println!(
|
||||
"virtual {label} created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
|
||||
`ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s."
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let (mut i, mut last_write) = (0i32, Instant::now());
|
||||
while Instant::now() < deadline {
|
||||
let fb = pad.service(0);
|
||||
if let Some((low, high)) = fb.rumble {
|
||||
println!(" rumble from kernel/game: low={low} high={high}");
|
||||
}
|
||||
for o in fb.hidout {
|
||||
println!(" hid output from kernel/game: {o:?}");
|
||||
}
|
||||
if last_write.elapsed() >= Duration::from_millis(300) {
|
||||
last_write = Instant::now();
|
||||
i += 1;
|
||||
let mut buttons = if i % 2 == 0 {
|
||||
punktfunk_core::input::gamepad::BTN_A
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if edge {
|
||||
// Cycle one paddle per beat (R4 → L4 → R5 → L5) so all four Edge slots
|
||||
// are visible in evtest / hidraw.
|
||||
buttons |= punktfunk_core::input::gamepad::BTN_PADDLE1 << (i % 4);
|
||||
}
|
||||
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
||||
let mut st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
|
||||
if edge {
|
||||
st.buttons[2] |= edge_paddle_bits(buttons);
|
||||
}
|
||||
pad.write_state(&st).context("write report")?;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
println!("dualsense-test: done");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
|
||||
/// streaming session): answers the full hid-nintendo probe conversation, then cycles the
|
||||
/// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
|
||||
/// light feedback. Verify with `evtest` (hid-nintendo input devices), `dmesg | grep
|
||||
/// nintendo`, SDL identifying a "Nintendo Switch Pro Controller".
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn switchpro_test(args: &[String]) -> Result<()> {
|
||||
use crate::inject::switch_pro::SwitchProPad;
|
||||
use crate::inject::switch_proto::SwitchState;
|
||||
let secs: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--seconds")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20);
|
||||
use std::time::{Duration, Instant};
|
||||
let mut pad =
|
||||
SwitchProPad::open(0).context("create virtual Switch Pro Controller via /dev/uhid")?;
|
||||
// Answer the driver's probe conversation promptly — every step blocks hid-nintendo
|
||||
// init until its reply lands; also stream neutral 0x30 reports like real hardware.
|
||||
println!("virtual Switch Pro created — servicing the hid-nintendo probe…");
|
||||
let init = Instant::now() + Duration::from_millis(2500);
|
||||
let mut hb = Instant::now();
|
||||
while Instant::now() < init {
|
||||
let fb = pad.service(0);
|
||||
for o in fb.hidout {
|
||||
println!(" probe feedback: {o:?}");
|
||||
}
|
||||
if hb.elapsed() >= Duration::from_millis(15) {
|
||||
hb = Instant::now();
|
||||
let _ = pad.write_state(&SwitchState::neutral());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
println!("probe window over — cycling buttons + stick for {secs}s (check evtest)");
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let (mut i, mut last_write) = (0i32, Instant::now());
|
||||
while Instant::now() < deadline {
|
||||
let fb = pad.service(0);
|
||||
if let Some((low, high)) = fb.rumble {
|
||||
println!(" rumble from kernel/game: low={low} high={high}");
|
||||
}
|
||||
for o in fb.hidout {
|
||||
println!(" hid output from kernel/game: {o:?}");
|
||||
}
|
||||
// ~15 ms cadence = the real controller's report rate (also keeps the driver's
|
||||
// post-probe subcommand rate limiter fed).
|
||||
if last_write.elapsed() >= Duration::from_millis(15) {
|
||||
last_write = Instant::now();
|
||||
i += 1;
|
||||
let step = i / 20; // change the pressed button every ~300 ms
|
||||
let buttons = if step % 2 == 0 {
|
||||
punktfunk_core::input::gamepad::BTN_A
|
||||
} else {
|
||||
punktfunk_core::input::gamepad::BTN_B
|
||||
};
|
||||
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
||||
let st = SwitchState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
|
||||
pad.write_state(&st).context("write Switch Pro report")?;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
println!("switchpro-test: done");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows N4 SPIKE (gamepad-new-types §6): hold a software-devnode HID Steam Deck
|
||||
/// (28DE:1205 via device_type 3) and watch whether Steam Input promotes it. Needs the
|
||||
/// updated signed driver installed + Steam running. `--seconds N` (default 120).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn deck_windows_spike(args: &[String]) -> Result<()> {
|
||||
let secs: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--seconds")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(120);
|
||||
crate::inject::dualsense_windows::deck_spike_hold(0, secs)
|
||||
}
|
||||
|
||||
/// Windows: create a virtual DualSense via the UMDF driver (a SwDeviceCreate per-session
|
||||
/// devnode plus the shared-memory channel) and hold it, pushing one fixed frame (Cross +
|
||||
/// LS-right). Drives the real DualSenseWindowsManager, so it validates the device lifecycle
|
||||
/// end to end. Verify while it holds: `Get-PnpDevice` shows a VID_054C device, and a HID read
|
||||
/// returns the pushed report (byte1=0xC0, byte8=0x28). On exit the pad drops → SwDeviceClose
|
||||
/// removes the devnode.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame};
|
||||
use std::time::{Duration, Instant};
|
||||
let secs: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--seconds")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20);
|
||||
// `--index N` creates pad `pf_pad_N` (default 0) — use a spare index (e.g. 1) to test
|
||||
// alongside a running host that already holds pad 0. `--ds4` drives the DualShock 4
|
||||
// backend instead of the DualSense one.
|
||||
let idx: u8 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--index")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let ds4 = args.iter().any(|a| a == "--ds4");
|
||||
let xbox = args.iter().any(|a| a == "--xbox");
|
||||
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
|
||||
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
|
||||
// report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend
|
||||
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
|
||||
let edge = args.iter().any(|a| a == "--edge");
|
||||
let deck = args.iter().any(|a| a == "--deck");
|
||||
let extra_buttons: u32 = if edge || deck {
|
||||
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Same drive loop for either backend (identical method surface): Arrival creates the pad,
|
||||
// State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback.
|
||||
macro_rules! drive {
|
||||
($mgr:expr, $label:expr) => {{
|
||||
let mut mgr = $mgr;
|
||||
mgr.handle(&GamepadEvent::Arrival {
|
||||
index: idx,
|
||||
kind: 2,
|
||||
capabilities: 0,
|
||||
});
|
||||
println!(
|
||||
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
|
||||
it in joy.cpl / Steam / a game; any feedback the game sends prints below.",
|
||||
$label
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let (mut i, mut last) = (0i32, Instant::now());
|
||||
while Instant::now() < deadline {
|
||||
mgr.pump(
|
||||
|pad, lo, hi| println!(" rumble from game: pad={pad} low={lo} high={hi}"),
|
||||
|o| println!(" hid output from game: {o:?}"),
|
||||
);
|
||||
if last.elapsed() >= Duration::from_millis(400) {
|
||||
last = Instant::now();
|
||||
i += 1;
|
||||
let buttons = if i % 2 == 0 {
|
||||
punktfunk_core::input::gamepad::BTN_A | extra_buttons // Cross (+ Edge paddles)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
||||
mgr.handle(&GamepadEvent::State(GamepadFrame {
|
||||
index: idx as i16,
|
||||
active_mask: 1 << idx,
|
||||
buttons,
|
||||
left_trigger: 0,
|
||||
right_trigger: 0,
|
||||
ls_x: lx,
|
||||
ls_y: 0,
|
||||
rs_x: 0,
|
||||
rs_y: 0,
|
||||
}));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}};
|
||||
}
|
||||
if xbox {
|
||||
// Xbox 360 via the XUSB companion: a different surface (handle + pump_rumble, no
|
||||
// HID-output plane), so drive it inline rather than via the macro.
|
||||
let mut mgr = crate::inject::gamepad::GamepadManager::new();
|
||||
mgr.handle(&GamepadEvent::Arrival {
|
||||
index: idx,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
});
|
||||
println!(
|
||||
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
|
||||
an XInput game or xinputtest.exe."
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let mut t = 0i32;
|
||||
while Instant::now() < deadline {
|
||||
mgr.pump_rumble(|pad, lo, hi| {
|
||||
println!(" rumble from game: pad={pad} low={lo} high={hi}")
|
||||
});
|
||||
t += 1;
|
||||
let lx = (((t % 200) - 100) * 327).clamp(-32768, 32767) as i16; // sweep ±32700
|
||||
let buttons = if (t / 67) % 2 == 0 {
|
||||
punktfunk_core::input::gamepad::BTN_A
|
||||
} else {
|
||||
0
|
||||
};
|
||||
mgr.handle(&GamepadEvent::State(GamepadFrame {
|
||||
index: idx as i16,
|
||||
active_mask: 1 << idx,
|
||||
buttons,
|
||||
left_trigger: 0,
|
||||
right_trigger: 0,
|
||||
ls_x: lx,
|
||||
ls_y: 0,
|
||||
rs_x: 0,
|
||||
rs_y: 0,
|
||||
}));
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
} else if ds4 {
|
||||
drive!(
|
||||
crate::inject::dualshock4_windows::DualShock4WindowsManager::new(),
|
||||
"DualShock 4"
|
||||
);
|
||||
} else if edge {
|
||||
drive!(
|
||||
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
|
||||
"DualSense Edge"
|
||||
);
|
||||
} else if deck {
|
||||
drive!(
|
||||
crate::inject::steam_deck_windows::SteamDeckWindowsManager::new(),
|
||||
"Steam Deck"
|
||||
);
|
||||
} else {
|
||||
drive!(
|
||||
crate::inject::dualsense_windows::DualSenseWindowsManager::new(),
|
||||
"DualSense"
|
||||
);
|
||||
}
|
||||
println!("dualsense-windows-test: done (devnode removed)");
|
||||
Ok(())
|
||||
}
|
||||
@@ -10,92 +10,10 @@
|
||||
use crate::capture::{CapturedFrame, PixelFormat};
|
||||
use anyhow::Result;
|
||||
|
||||
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
||||
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
||||
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
|
||||
/// stream and a self-contained AU for the wire.
|
||||
pub struct EncodedFrame {
|
||||
pub data: Vec<u8>,
|
||||
pub pts_ns: u64,
|
||||
/// True for IDR/keyframes (sets the SOF/keyframe wire flags).
|
||||
pub keyframe: bool,
|
||||
/// True when this AU is a **reference-frame-invalidation recovery frame** — a clean P-frame the
|
||||
/// encoder coded against a known-good reference in response to
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames). The pump tags it
|
||||
/// [`punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR`] so the client lifts its post-loss
|
||||
/// freeze on it without an IDR. Set by BOTH RFI backends: native AMF (the LTR force-reference
|
||||
/// frame) and Windows direct-NVENC (the first frame encoded after `nvEncInvalidateRefFrames` —
|
||||
/// the invalidation applies at the next `encode_picture`, so that AU is by construction the
|
||||
/// clean re-anchor). Without it the client's freeze can only lift on an IDR — which the host
|
||||
/// suppresses after a successful RFI (the cooldown), a ~1 s frozen stall per loss event.
|
||||
pub recovery_anchor: bool,
|
||||
/// The AU is shard-aligned self-delimiting chunks (see [`Encoder::set_wire_chunking`]);
|
||||
/// the session stamps [`punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED`] so the client
|
||||
/// windows its parse and may opt into partial delivery. Only the PyroWave backend sets it.
|
||||
pub chunk_aligned: bool,
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Codec {
|
||||
H264,
|
||||
H265,
|
||||
Av1,
|
||||
/// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md).
|
||||
/// Only ever negotiated via the client's explicit `preferred_codec` (never the precedence
|
||||
/// ladder) and only emitted by the `pyrowave`-feature backend; every AU is a keyframe.
|
||||
PyroWave,
|
||||
}
|
||||
|
||||
/// Chroma subsampling the encoder emits, negotiated with the client (the `PUNKTFUNK_444` gate + the
|
||||
/// client's `VIDEO_CAP_444` + a GPU probe). `Yuv420` is the universal default; `Yuv444` is HEVC-only,
|
||||
/// native-protocol-only (GameStream stays 4:2:0), and the host only ever passes it after
|
||||
/// [`can_encode_444`] confirmed the active backend supports it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum ChromaFormat {
|
||||
#[default]
|
||||
Yuv420,
|
||||
Yuv444,
|
||||
}
|
||||
|
||||
impl ChromaFormat {
|
||||
/// The HEVC `chroma_format_idc` this maps to: `1` (4:2:0) or `3` (4:4:4). Also the wire value
|
||||
/// echoed in [`punktfunk_core::quic::Welcome::chroma_format`].
|
||||
pub fn idc(self) -> u8 {
|
||||
match self {
|
||||
ChromaFormat::Yuv420 => punktfunk_core::quic::CHROMA_IDC_420,
|
||||
ChromaFormat::Yuv444 => punktfunk_core::quic::CHROMA_IDC_444,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for full-chroma 4:4:4.
|
||||
pub fn is_444(self) -> bool {
|
||||
matches!(self, ChromaFormat::Yuv444)
|
||||
}
|
||||
}
|
||||
mod codec;
|
||||
pub(crate) use codec::*;
|
||||
|
||||
impl Codec {
|
||||
/// Map a negotiated `quic` codec bit ([`punktfunk_core::quic::CODEC_H264`] etc.) to the encoder
|
||||
/// [`Codec`]. Unknown / `0` → HEVC (the pre-negotiation default). Inverse of [`Codec::to_wire`].
|
||||
pub fn from_wire(bit: u8) -> Codec {
|
||||
match bit {
|
||||
punktfunk_core::quic::CODEC_H264 => Codec::H264,
|
||||
punktfunk_core::quic::CODEC_AV1 => Codec::Av1,
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => Codec::PyroWave,
|
||||
_ => Codec::H265,
|
||||
}
|
||||
}
|
||||
|
||||
/// The single `quic` codec bit for this codec (echoed in [`punktfunk_core::quic::Welcome::codec`]).
|
||||
pub fn to_wire(self) -> u8 {
|
||||
match self {
|
||||
Codec::H264 => punktfunk_core::quic::CODEC_H264,
|
||||
Codec::H265 => punktfunk_core::quic::CODEC_HEVC,
|
||||
Codec::Av1 => punktfunk_core::quic::CODEC_AV1,
|
||||
Codec::PyroWave => punktfunk_core::quic::CODEC_PYROWAVE,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `quic` codec bitfield the host can currently **emit** on the punktfunk/1 native path,
|
||||
/// given the resolved encode backend — the same GPU-aware advertisement GameStream builds for
|
||||
/// Moonlight ([`crate::gamestream::serverinfo`]), in `quic::CODEC_*` bits. The GPU-less software
|
||||
@@ -173,275 +91,6 @@ impl Codec {
|
||||
})();
|
||||
base | pyro
|
||||
}
|
||||
|
||||
/// Lowercase stats/console label (`"h264"` / `"hevc"` / `"av1"`) — the codec string seeded into
|
||||
/// the web console's session meta ([`crate::stats_recorder::StatsRecorder::register_session`]).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264",
|
||||
Codec::H265 => "hevc",
|
||||
Codec::Av1 => "av1",
|
||||
Codec::PyroWave => "pyrowave",
|
||||
}
|
||||
}
|
||||
|
||||
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
|
||||
/// pick the software encoder).
|
||||
pub fn nvenc_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_nvenc",
|
||||
Codec::H265 => "hevc_nvenc",
|
||||
Codec::Av1 => "av1_nvenc",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The FFmpeg VAAPI encoder name (AMD via Mesa `radeonsi`, Intel via `iHD`/`i965`). One
|
||||
/// libavcodec encoder per codec covers both vendors — the kernel driver differs, the libva
|
||||
/// userspace API is identical. Selected by name (the codec id would pick the SW encoder).
|
||||
/// AV1 VAAPI encode is narrow (Intel Arc/Xe2+, AMD RDNA3+/RDNA4) — gate it on a capability
|
||||
/// probe, never assume it (see [`open_video`]).
|
||||
pub fn vaapi_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_vaapi",
|
||||
Codec::H265 => "hevc_vaapi",
|
||||
Codec::Av1 => "av1_vaapi",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The FFmpeg AMD **AMF** encoder name (the Windows AMD backend). Selected by name (the codec id
|
||||
/// would pick the software encoder). AV1 (`av1_amf`) is RDNA3+/RX 7000+ — probe, never assume.
|
||||
pub fn amf_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_amf",
|
||||
Codec::H265 => "hevc_amf",
|
||||
Codec::Av1 => "av1_amf",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The FFmpeg Intel **QSV** encoder name (the Windows Intel backend). Selected by name. AV1
|
||||
/// (`av1_qsv`) is Arc/Xe2+; HEVC Main10 is Gen9.5+ — probe, never assume.
|
||||
pub fn qsv_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_qsv",
|
||||
Codec::H265 => "hevc_qsv",
|
||||
Codec::Av1 => "av1_qsv",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
|
||||
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
|
||||
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncoderCaps {
|
||||
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
|
||||
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
|
||||
/// Two backends implement RFI: Windows direct-NVENC (`nvEncInvalidateRefFrames`) and native
|
||||
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
|
||||
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
|
||||
pub supports_rfi: bool,
|
||||
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||
/// Windows direct-NVENC path attaches it today.
|
||||
pub supports_hdr_metadata: bool,
|
||||
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
|
||||
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
|
||||
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
|
||||
/// from the pre-open probe, so this is a *post-open cross-check*: the session glue logs loudly if
|
||||
/// the encoder's real chroma disagrees with what was negotiated (the in-band SPS is authoritative
|
||||
/// for the decoder either way).
|
||||
pub chroma_444: bool,
|
||||
/// The encoder runs a periodic **intra-refresh wave** — a moving band of intra blocks that
|
||||
/// re-codes the whole picture over ~0.5 s, no periodic IDR. FEC-unrecoverable loss self-heals as
|
||||
/// the band sweeps, so the session glue rate-limits client keyframe requests instead of answering
|
||||
/// each with a full IDR (the 20-40× frame-size spike that cascades under loss). Linux NVENC / AMF
|
||||
/// set it when `PUNKTFUNK_INTRA_REFRESH` opened the encoder in that mode; VAAPI/QSV/software never
|
||||
/// do. NOTE — the wave carries NO decoder-visible clean-point: FFmpeg never sets `AV_FRAME_FLAG_KEY`
|
||||
/// at a recovery point (H.264 flags key only when `recovery_frame_cnt == 0`; HEVC only on IRAP),
|
||||
/// and AMF emits no recovery-point SEI at all. So this cap ALONE does not let the client lift its
|
||||
/// post-loss freeze without an IDR — that needs [`intra_refresh_recovery`](Self::intra_refresh_recovery).
|
||||
pub intra_refresh: bool,
|
||||
/// The intra-refresh wave is a *validated constrained GDR* — verified on real hardware to fully
|
||||
/// heal a lost picture within one wave period with no residual artifacts. Only then does the host
|
||||
/// tag each wave-boundary AU with [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT),
|
||||
/// so the client can lift its freeze on the second mark (a proven clean re-anchor) instead of
|
||||
/// waiting out its backstop and forcing a full IDR. Default `false` on every backend until on-glass
|
||||
/// validation flips it — an un-validated encoder keeps the IDR recovery path, so this is inert and
|
||||
/// cannot regress. Meaningless unless [`intra_refresh`](Self::intra_refresh) is also set.
|
||||
pub intra_refresh_recovery: bool,
|
||||
/// Length of the intra-refresh wave in frames — the boundary period the host marks on (it sets
|
||||
/// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh
|
||||
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
|
||||
pub intra_refresh_period: u32,
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
pub trait Encoder: Send {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
||||
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
||||
/// session glue predicts it exactly as `AUs sent so far + frames in flight` (AUs are emitted
|
||||
/// FIFO, one per submission; anything that would break the prediction — an in-place reset, a
|
||||
/// device-change teardown, an encoder rebuild — forfeits the in-flight frames on BOTH sides
|
||||
/// and clears the encoder's reference state, so stale predictions die with it). The RFI
|
||||
/// backends pin their frame numbering (LTR marks, DPB timestamps) to this so
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) compares client frame numbers
|
||||
/// against the same domain — an encoder-internal counter desyncs from the wire on the first
|
||||
/// mid-stream rebuild (adaptive bitrate steps do this under congestion, exactly when losses
|
||||
/// happen). Default: ignore the index and delegate to `submit` (backends without per-frame
|
||||
/// reference bookkeeping don't care).
|
||||
fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
|
||||
let _ = wire_index;
|
||||
self.submit(frame)
|
||||
}
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
||||
/// route by query rather than rely on the no-op/`false` defaults of
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
||||
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
|
||||
/// path overrides it.
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps::default()
|
||||
}
|
||||
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
|
||||
/// reference-frame-invalidation request). Default: no-op.
|
||||
fn request_keyframe(&mut self) {}
|
||||
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
|
||||
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
|
||||
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
|
||||
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
|
||||
/// every frame; only the direct-NVENC path consumes it.
|
||||
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
|
||||
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
|
||||
/// bookkeeping to) so the encoder re-references an older still-valid frame instead of emitting
|
||||
/// a full IDR. Returns `true` if a real reference invalidation was performed; `false` means the
|
||||
/// encoder couldn't (range older than the DPB/LTR history, or the backend has no RFI) and the
|
||||
/// caller should fall back to [`request_keyframe`](Self::request_keyframe). Default: `false` —
|
||||
/// the Windows direct-NVENC path (`nvEncInvalidateRefFrames`) and native AMF (LTR
|
||||
/// force-reference) implement true RFI; the libavcodec paths can't express it, so they keyframe.
|
||||
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
||||
false
|
||||
}
|
||||
/// Pull the next encoded AU if one is ready.
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
||||
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
|
||||
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
|
||||
/// `true` when the encoder was rebuilt: every submitted-but-unpolled frame is forfeited and
|
||||
/// the next submitted frame starts a fresh stream (IDR). Default `false`: the backend has no
|
||||
/// in-place rebuild and the caller must treat the stall as fatal instead.
|
||||
fn reset(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Retarget the encoder's rate control to `bps` (average == max, CBR) **in place** — same
|
||||
/// codec/resolution/fps, only the bitrate and its derived VBV move. Returns `true` when the
|
||||
/// live encoder accepted the change: the reference chain, the in-flight frames and the
|
||||
/// caller's wire-index prediction all survive, so an adaptive-bitrate step costs *nothing* on
|
||||
/// the wire (no IDR, no in-flight forfeit — the whole point vs. a rebuild). `false` = the
|
||||
/// backend can't (or the driver rejected the new rate, e.g. above the codec-level ceiling) —
|
||||
/// the caller falls back to its full rebuild path, which also owns the bitrate clamping.
|
||||
/// Default: no in-place retarget (the libavcodec/software paths).
|
||||
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
||||
false
|
||||
}
|
||||
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
|
||||
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
|
||||
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
|
||||
/// datagram costs a few coefficient blocks, not the frame. AUs produced this way are
|
||||
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
|
||||
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
|
||||
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
|
||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||
fn flush(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
/// Maximum encodable dimension (px) per side for this codec on NVENC. H.264 tops out at
|
||||
/// 4096 (level constraint); HEVC and AV1 allow 8192. Used to reject out-of-range client
|
||||
/// modes up front (see [`validate_dimensions`]).
|
||||
pub fn max_dimension(self) -> u32 {
|
||||
match self {
|
||||
Codec::H264 => 4096,
|
||||
// PyroWave has no codec-level dimension cap (arbitrary even sizes); 8192 matches the
|
||||
// buffer-math guard the other codecs get.
|
||||
Codec::H265 | Codec::Av1 | Codec::PyroWave => 8192,
|
||||
}
|
||||
}
|
||||
|
||||
/// The codec's *spec* top level/tier bitrate (bits/s) — the usual boundary at which NVENC
|
||||
/// starts rejecting `avcodec_open2` with EINVAL. NOT a hard cap: [`open_video`](crate::encode::
|
||||
/// open_video) probes the actual GPU ceiling by stepping DOWN from the requested bitrate only on
|
||||
/// EINVAL, and uses this purely as the first step-down candidate (so a card that accepts more —
|
||||
/// an RTX 5070 Ti does >1 Gbps HEVC where a 4090 caps at ~800 Mbps — is never clamped to it).
|
||||
/// HEVC Level 6.2 High tier = 800 Mbps; H.264 High level 6.2 ≈ 480 Mbps; AV1's levels allow more.
|
||||
pub fn max_bitrate_bps(self) -> u64 {
|
||||
match self {
|
||||
Codec::H264 => 480_000_000,
|
||||
Codec::H265 => 800_000_000,
|
||||
Codec::Av1 => 1_200_000_000,
|
||||
// No spec level/tier: the rate is a plain per-frame byte budget. Use the protocol's
|
||||
// own bitrate clamp so the step-down probe logic never binds below it.
|
||||
Codec::PyroWave => 8_000_000_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
|
||||
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
|
||||
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
|
||||
/// direct-NVENC paths (which used to hardwire 1 frame) to parity. Larger values let complex
|
||||
/// frames borrow bits — better rate utilization at the cost of per-frame size variance.
|
||||
pub(crate) fn vbv_frames_env() -> f64 {
|
||||
std::env::var("PUNKTFUNK_VBV_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
|
||||
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
|
||||
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
||||
/// `mode=WxHxFPS`, so this is the gate on attacker/typo-controlled dimensions.
|
||||
pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()> {
|
||||
if width == 0 || height == 0 {
|
||||
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be non-zero");
|
||||
}
|
||||
// NVENC requires even dimensions for the chroma subsampling it does internally.
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be even");
|
||||
}
|
||||
// PyroWave's 5-level wavelet decomposition needs ≥ 4·2⁵ px per axis (upstream
|
||||
// `MinimumImageSize` — the band mirroring breaks below it); reject a tiny mode here
|
||||
// (e.g. a match-window resize dragged to a sliver) instead of failing the encoder
|
||||
// rebuild after the switch was acked.
|
||||
if codec == Codec::PyroWave && (width < 128 || height < 128) {
|
||||
anyhow::bail!(
|
||||
"invalid PyroWave resolution {width}x{height}: the wavelet needs at least 128px per axis"
|
||||
);
|
||||
}
|
||||
let max = codec.max_dimension();
|
||||
if width > max || height > max {
|
||||
anyhow::bail!(
|
||||
"{codec:?} max dimension is {max}px; requested {width}x{height} \
|
||||
(use HEVC/AV1 above 4096, or lower the client resolution)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a hardware video encoder for frames of the given `format` and mode, selecting the GPU
|
||||
@@ -1196,6 +845,76 @@ pub fn can_encode_444(_codec: Codec) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the active GPU encode backend can actually produce a **10-bit** stream for `codec`
|
||||
/// (HEVC Main10 / AV1 10-bit). Resolved (and cached per selected GPU) *before* the Welcome so the
|
||||
/// negotiated bit depth — and the HDR/SDR colour label derived from it — matches what the encoder
|
||||
/// will really emit: the honest-downgrade channel, exactly like [`can_encode_444`]. Without this
|
||||
/// gate a default-on `PUNKTFUNK_10BIT` would negotiate 10-bit on a GPU/backend that then silently
|
||||
/// falls back to 8-bit post-Welcome (label HDR / stream SDR).
|
||||
///
|
||||
/// Backend truth: Windows **NVENC** queries the per-codec `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` cap;
|
||||
/// native **AMF** `Init`s a tiny P010 encoder with the 10-bit profile props (the driver rejects
|
||||
/// what the VCN can't do). **QSV** stays `false` until validated on Intel glass — the libavcodec
|
||||
/// Main10 incantation can silently encode 8-bit, the same stance as its 4:4:4 probe. Every
|
||||
/// **Linux** backend is `false` today: direct-NVENC/CUDA pins 8-bit until a P010 capture path
|
||||
/// exists (Phase 5.1), libav `hevc_nvenc` needs a 10-bit input format the capturer never feeds,
|
||||
/// VAAPI 10-bit isn't wired, and Vulkan-video hardcodes 8-bit — so Linux hosts honestly negotiate
|
||||
/// 8-bit SDR.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub fn can_encode_10bit(codec: Codec) -> bool {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
if !codec.supports_10bit() {
|
||||
return false;
|
||||
}
|
||||
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
|
||||
// selected adapter before the next Welcome, mirroring `can_encode_444`.
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||||
let key = (crate::gpu::selection_key(), codec.label());
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||||
return *v;
|
||||
}
|
||||
let supported = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// No Linux backend encodes 10-bit yet (see the fn doc) — never negotiate it.
|
||||
false
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
match windows_resolved_backend() {
|
||||
WindowsBackend::Nvenc => {
|
||||
#[cfg(feature = "nvenc")]
|
||||
{
|
||||
nvenc::probe_can_encode_10bit(codec)
|
||||
}
|
||||
#[cfg(not(feature = "nvenc"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
WindowsBackend::Amf => amf::probe_can_encode_10bit(codec),
|
||||
// QSV: deferred like its 4:4:4 probe (`ffmpeg_win::probe_can_encode_444`) — no
|
||||
// Intel Windows box in the lab to validate that the libavcodec profile really
|
||||
// emits Main10 rather than silently 8-bit.
|
||||
WindowsBackend::Qsv => false,
|
||||
WindowsBackend::Software => false,
|
||||
}
|
||||
}
|
||||
};
|
||||
tracing::info!(codec = ?codec, supported, "10-bit encode capability probed");
|
||||
cache.lock().unwrap().insert(key, supported);
|
||||
supported
|
||||
}
|
||||
|
||||
/// Non-Linux/Windows (the macOS dev/test build of the host — synthetic-source loopback only):
|
||||
/// no GPU encode backend exists here, so 10-bit is never negotiated.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub fn can_encode_10bit(_codec: Codec) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
|
||||
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the vendor of the
|
||||
@@ -1379,6 +1098,20 @@ mod nvenc;
|
||||
#[cfg(all(target_os = "linux", feature = "nvenc"))]
|
||||
#[path = "encode/linux/nvenc_cuda.rs"]
|
||||
mod nvenc_cuda;
|
||||
// Actionable `NVENCSTATUS` → cause mapping shared by both direct-NVENC backends, so a failed
|
||||
// session open logs "update/reboot the driver" instead of the old misleading "(no NVIDIA GPU?)".
|
||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
||||
#[path = "encode/nvenc_status.rs"]
|
||||
mod nvenc_status;
|
||||
// Platform-agnostic direct-SDK NVENC glue (`NvStatusExt`/`nv_ok`, `codec_guid`) shared by both
|
||||
// `nvEncodeAPI` backends — the byte-identical Tier-2 leaves (plan §2.2). Sibling of `nvenc_status`.
|
||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
||||
#[path = "encode/nvenc_core.rs"]
|
||||
mod nvenc_core;
|
||||
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
|
||||
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
|
||||
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
|
||||
mod libav;
|
||||
// Software (openh264) H.264 encoder — the GPU-less path on BOTH Windows and Linux (a headless /
|
||||
// GPU-less test box, or a fallback when no hardware encoder is available). Platform-agnostic: it
|
||||
// consumes CPU RGB `CapturedFrame`s and the statically-bundled openh264 build.
|
||||
@@ -1420,41 +1153,6 @@ mod pyrowave;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_and_odd_dimensions() {
|
||||
assert!(validate_dimensions(Codec::H265, 0, 1080).is_err());
|
||||
assert!(validate_dimensions(Codec::H265, 1920, 0).is_err());
|
||||
assert!(validate_dimensions(Codec::H265, 1921, 1080).is_err()); // odd width
|
||||
assert!(validate_dimensions(Codec::H265, 1920, 1081).is_err()); // odd height
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_capped_at_4096() {
|
||||
assert!(validate_dimensions(Codec::H264, 3840, 2160).is_ok()); // 4K fits (width < 4096)
|
||||
assert!(validate_dimensions(Codec::H264, 4096, 4096).is_ok()); // exactly at the limit
|
||||
assert!(validate_dimensions(Codec::H264, 4098, 2160).is_err());
|
||||
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_and_av1_allow_up_to_8192() {
|
||||
for c in [Codec::H265, Codec::Av1] {
|
||||
assert!(validate_dimensions(c, 3840, 2160).is_ok());
|
||||
assert!(validate_dimensions(c, 7680, 4320).is_ok()); // 8K fits
|
||||
assert!(validate_dimensions(c, 8192, 8192).is_ok());
|
||||
assert!(validate_dimensions(c, 8194, 4320).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_modes_accepted() {
|
||||
for c in [Codec::H264, Codec::H265, Codec::Av1] {
|
||||
for (w, h) in [(1280, 720), (1920, 1080), (2560, 1440)] {
|
||||
assert!(validate_dimensions(c, w, h).is_ok(), "{c:?} {w}x{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The probed-capability → wire-bitfield mapping the native codec advertisement is built from.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[test]
|
||||
@@ -1481,15 +1179,4 @@ mod tests {
|
||||
};
|
||||
assert_eq!(none.wire_mask(), None);
|
||||
}
|
||||
|
||||
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
|
||||
#[test]
|
||||
fn codec_wire_roundtrip_and_label() {
|
||||
for c in [Codec::H264, Codec::H265, Codec::Av1] {
|
||||
assert_eq!(Codec::from_wire(c.to_wire()), c);
|
||||
}
|
||||
assert_eq!(Codec::H264.label(), "h264");
|
||||
assert_eq!(Codec::H265.label(), "hevc");
|
||||
assert_eq!(Codec::Av1.label(), "av1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
//! The encoder contract (plan §7, Tier 1): the [`Encoder`] trait plus the plain-data value types its
|
||||
//! signatures use — [`EncodedFrame`], [`Codec`], [`ChromaFormat`], [`EncoderCaps`] — and the
|
||||
//! dimension/VBV helpers [`validate_dimensions`] and [`vbv_frames_env`]. Backend selection, the
|
||||
//! capability probes that mirror it, and `Codec::host_wire_caps` stay in the parent [`crate::encode`]
|
||||
//! facade, which re-exports this module (`pub(crate) use codec::*;`) so every `crate::encode::*` path
|
||||
//! is unchanged.
|
||||
use crate::capture::CapturedFrame;
|
||||
use anyhow::Result;
|
||||
|
||||
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
||||
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
||||
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
|
||||
/// stream and a self-contained AU for the wire.
|
||||
pub struct EncodedFrame {
|
||||
pub data: Vec<u8>,
|
||||
pub pts_ns: u64,
|
||||
/// True for IDR/keyframes (sets the SOF/keyframe wire flags).
|
||||
pub keyframe: bool,
|
||||
/// True when this AU is a **reference-frame-invalidation recovery frame** — a clean P-frame the
|
||||
/// encoder coded against a known-good reference in response to
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames). The pump tags it
|
||||
/// [`punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR`] so the client lifts its post-loss
|
||||
/// freeze on it without an IDR. Set by BOTH RFI backends: native AMF (the LTR force-reference
|
||||
/// frame) and Windows direct-NVENC (the first frame encoded after `nvEncInvalidateRefFrames` —
|
||||
/// the invalidation applies at the next `encode_picture`, so that AU is by construction the
|
||||
/// clean re-anchor). Without it the client's freeze can only lift on an IDR — which the host
|
||||
/// suppresses after a successful RFI (the cooldown), a ~1 s frozen stall per loss event.
|
||||
pub recovery_anchor: bool,
|
||||
/// The AU is shard-aligned self-delimiting chunks (see [`Encoder::set_wire_chunking`]);
|
||||
/// the session stamps [`punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED`] so the client
|
||||
/// windows its parse and may opt into partial delivery. Only the PyroWave backend sets it.
|
||||
pub chunk_aligned: bool,
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Codec {
|
||||
H264,
|
||||
H265,
|
||||
Av1,
|
||||
/// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md).
|
||||
/// Only ever negotiated via the client's explicit `preferred_codec` (never the precedence
|
||||
/// ladder) and only emitted by the `pyrowave`-feature backend; every AU is a keyframe.
|
||||
PyroWave,
|
||||
}
|
||||
|
||||
/// Chroma subsampling the encoder emits, negotiated with the client (the `PUNKTFUNK_444` gate + the
|
||||
/// client's `VIDEO_CAP_444` + a GPU probe). `Yuv420` is the universal default; `Yuv444` is HEVC-only,
|
||||
/// native-protocol-only (GameStream stays 4:2:0), and the host only ever passes it after
|
||||
/// [`can_encode_444`] confirmed the active backend supports it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum ChromaFormat {
|
||||
#[default]
|
||||
Yuv420,
|
||||
Yuv444,
|
||||
}
|
||||
|
||||
impl ChromaFormat {
|
||||
/// The HEVC `chroma_format_idc` this maps to: `1` (4:2:0) or `3` (4:4:4). Also the wire value
|
||||
/// echoed in [`punktfunk_core::quic::Welcome::chroma_format`].
|
||||
pub fn idc(self) -> u8 {
|
||||
match self {
|
||||
ChromaFormat::Yuv420 => punktfunk_core::quic::CHROMA_IDC_420,
|
||||
ChromaFormat::Yuv444 => punktfunk_core::quic::CHROMA_IDC_444,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for full-chroma 4:4:4.
|
||||
pub fn is_444(self) -> bool {
|
||||
matches!(self, ChromaFormat::Yuv444)
|
||||
}
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
/// Map a negotiated `quic` codec bit ([`punktfunk_core::quic::CODEC_H264`] etc.) to the encoder
|
||||
/// [`Codec`]. Unknown / `0` → HEVC (the pre-negotiation default). Inverse of [`Codec::to_wire`].
|
||||
pub fn from_wire(bit: u8) -> Codec {
|
||||
match bit {
|
||||
punktfunk_core::quic::CODEC_H264 => Codec::H264,
|
||||
punktfunk_core::quic::CODEC_AV1 => Codec::Av1,
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => Codec::PyroWave,
|
||||
_ => Codec::H265,
|
||||
}
|
||||
}
|
||||
|
||||
/// The single `quic` codec bit for this codec (echoed in [`punktfunk_core::quic::Welcome::codec`]).
|
||||
pub fn to_wire(self) -> u8 {
|
||||
match self {
|
||||
Codec::H264 => punktfunk_core::quic::CODEC_H264,
|
||||
Codec::H265 => punktfunk_core::quic::CODEC_HEVC,
|
||||
Codec::Av1 => punktfunk_core::quic::CODEC_AV1,
|
||||
Codec::PyroWave => punktfunk_core::quic::CODEC_PYROWAVE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase stats/console label (`"h264"` / `"hevc"` / `"av1"`) — the codec string seeded into
|
||||
/// the web console's session meta ([`crate::stats_recorder::StatsRecorder::register_session`]).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264",
|
||||
Codec::H265 => "hevc",
|
||||
Codec::Av1 => "av1",
|
||||
Codec::PyroWave => "pyrowave",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit).
|
||||
/// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation
|
||||
/// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the
|
||||
/// *codec-level* gate: the active GPU/backend must still pass
|
||||
/// [`can_encode_10bit`](crate::encode::can_encode_10bit) before the host negotiates 10-bit.
|
||||
pub fn supports_10bit(self) -> bool {
|
||||
matches!(self, Codec::H265 | Codec::Av1)
|
||||
}
|
||||
|
||||
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
|
||||
/// pick the software encoder).
|
||||
pub fn nvenc_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_nvenc",
|
||||
Codec::H265 => "hevc_nvenc",
|
||||
Codec::Av1 => "av1_nvenc",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The FFmpeg VAAPI encoder name (AMD via Mesa `radeonsi`, Intel via `iHD`/`i965`). One
|
||||
/// libavcodec encoder per codec covers both vendors — the kernel driver differs, the libva
|
||||
/// userspace API is identical. Selected by name (the codec id would pick the SW encoder).
|
||||
/// AV1 VAAPI encode is narrow (Intel Arc/Xe2+, AMD RDNA3+/RDNA4) — gate it on a capability
|
||||
/// probe, never assume it (see [`open_video`]).
|
||||
pub fn vaapi_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_vaapi",
|
||||
Codec::H265 => "hevc_vaapi",
|
||||
Codec::Av1 => "av1_vaapi",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The FFmpeg AMD **AMF** encoder name (the Windows AMD backend). Selected by name (the codec id
|
||||
/// would pick the software encoder). AV1 (`av1_amf`) is RDNA3+/RX 7000+ — probe, never assume.
|
||||
pub fn amf_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_amf",
|
||||
Codec::H265 => "hevc_amf",
|
||||
Codec::Av1 => "av1_amf",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The FFmpeg Intel **QSV** encoder name (the Windows Intel backend). Selected by name. AV1
|
||||
/// (`av1_qsv`) is Arc/Xe2+; HEVC Main10 is Gen9.5+ — probe, never assume.
|
||||
pub fn qsv_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_qsv",
|
||||
Codec::H265 => "hevc_qsv",
|
||||
Codec::Av1 => "av1_qsv",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
|
||||
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
|
||||
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncoderCaps {
|
||||
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
|
||||
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
|
||||
/// Two backends implement RFI: Windows direct-NVENC (`nvEncInvalidateRefFrames`) and native
|
||||
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
|
||||
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
|
||||
pub supports_rfi: bool,
|
||||
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||
/// Windows direct-NVENC path attaches it today.
|
||||
pub supports_hdr_metadata: bool,
|
||||
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
|
||||
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
|
||||
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
|
||||
/// from the pre-open probe, so this is a *post-open cross-check*: the session glue logs loudly if
|
||||
/// the encoder's real chroma disagrees with what was negotiated (the in-band SPS is authoritative
|
||||
/// for the decoder either way).
|
||||
pub chroma_444: bool,
|
||||
/// The encoder runs a periodic **intra-refresh wave** — a moving band of intra blocks that
|
||||
/// re-codes the whole picture over ~0.5 s, no periodic IDR. FEC-unrecoverable loss self-heals as
|
||||
/// the band sweeps, so the session glue rate-limits client keyframe requests instead of answering
|
||||
/// each with a full IDR (the 20-40× frame-size spike that cascades under loss). Linux NVENC / AMF
|
||||
/// set it when `PUNKTFUNK_INTRA_REFRESH` opened the encoder in that mode; VAAPI/QSV/software never
|
||||
/// do. NOTE — the wave carries NO decoder-visible clean-point: FFmpeg never sets `AV_FRAME_FLAG_KEY`
|
||||
/// at a recovery point (H.264 flags key only when `recovery_frame_cnt == 0`; HEVC only on IRAP),
|
||||
/// and AMF emits no recovery-point SEI at all. So this cap ALONE does not let the client lift its
|
||||
/// post-loss freeze without an IDR — that needs [`intra_refresh_recovery`](Self::intra_refresh_recovery).
|
||||
pub intra_refresh: bool,
|
||||
/// The intra-refresh wave is a *validated constrained GDR* — verified on real hardware to fully
|
||||
/// heal a lost picture within one wave period with no residual artifacts. Only then does the host
|
||||
/// tag each wave-boundary AU with [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT),
|
||||
/// so the client can lift its freeze on the second mark (a proven clean re-anchor) instead of
|
||||
/// waiting out its backstop and forcing a full IDR. Default `false` on every backend until on-glass
|
||||
/// validation flips it — an un-validated encoder keeps the IDR recovery path, so this is inert and
|
||||
/// cannot regress. Meaningless unless [`intra_refresh`](Self::intra_refresh) is also set.
|
||||
pub intra_refresh_recovery: bool,
|
||||
/// Length of the intra-refresh wave in frames — the boundary period the host marks on (it sets
|
||||
/// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh
|
||||
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
|
||||
pub intra_refresh_period: u32,
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
pub trait Encoder: Send {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
||||
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
||||
/// session glue predicts it exactly as `AUs sent so far + frames in flight` (AUs are emitted
|
||||
/// FIFO, one per submission; anything that would break the prediction — an in-place reset, a
|
||||
/// device-change teardown, an encoder rebuild — forfeits the in-flight frames on BOTH sides
|
||||
/// and clears the encoder's reference state, so stale predictions die with it). The RFI
|
||||
/// backends pin their frame numbering (LTR marks, DPB timestamps) to this so
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) compares client frame numbers
|
||||
/// against the same domain — an encoder-internal counter desyncs from the wire on the first
|
||||
/// mid-stream rebuild (adaptive bitrate steps do this under congestion, exactly when losses
|
||||
/// happen). Default: ignore the index and delegate to `submit` (backends without per-frame
|
||||
/// reference bookkeeping don't care).
|
||||
fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
|
||||
let _ = wire_index;
|
||||
self.submit(frame)
|
||||
}
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
||||
/// route by query rather than rely on the no-op/`false` defaults of
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
||||
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
|
||||
/// path overrides it.
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps::default()
|
||||
}
|
||||
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
|
||||
/// reference-frame-invalidation request). Default: no-op.
|
||||
fn request_keyframe(&mut self) {}
|
||||
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
|
||||
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
|
||||
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
|
||||
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
|
||||
/// every frame; only the direct-NVENC path consumes it.
|
||||
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
|
||||
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
|
||||
/// bookkeeping to) so the encoder re-references an older still-valid frame instead of emitting
|
||||
/// a full IDR. Returns `true` if a real reference invalidation was performed; `false` means the
|
||||
/// encoder couldn't (range older than the DPB/LTR history, or the backend has no RFI) and the
|
||||
/// caller should fall back to [`request_keyframe`](Self::request_keyframe). Default: `false` —
|
||||
/// the Windows direct-NVENC path (`nvEncInvalidateRefFrames`) and native AMF (LTR
|
||||
/// force-reference) implement true RFI; the libavcodec paths can't express it, so they keyframe.
|
||||
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
||||
false
|
||||
}
|
||||
/// Pull the next encoded AU if one is ready.
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
||||
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
|
||||
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
|
||||
/// `true` when the encoder was rebuilt: every submitted-but-unpolled frame is forfeited and
|
||||
/// the next submitted frame starts a fresh stream (IDR). Default `false`: the backend has no
|
||||
/// in-place rebuild and the caller must treat the stall as fatal instead.
|
||||
fn reset(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Retarget the encoder's rate control to `bps` (average == max, CBR) **in place** — same
|
||||
/// codec/resolution/fps, only the bitrate and its derived VBV move. Returns `true` when the
|
||||
/// live encoder accepted the change: the reference chain, the in-flight frames and the
|
||||
/// caller's wire-index prediction all survive, so an adaptive-bitrate step costs *nothing* on
|
||||
/// the wire (no IDR, no in-flight forfeit — the whole point vs. a rebuild). `false` = the
|
||||
/// backend can't (or the driver rejected the new rate, e.g. above the codec-level ceiling) —
|
||||
/// the caller falls back to its full rebuild path, which also owns the bitrate clamping.
|
||||
/// Default: no in-place retarget (the libavcodec/software paths).
|
||||
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
||||
false
|
||||
}
|
||||
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
|
||||
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
|
||||
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
|
||||
/// datagram costs a few coefficient blocks, not the frame. AUs produced this way are
|
||||
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
|
||||
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
|
||||
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
|
||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||
fn flush(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
/// Maximum encodable dimension (px) per side for this codec on NVENC. H.264 tops out at
|
||||
/// 4096 (level constraint); HEVC and AV1 allow 8192. Used to reject out-of-range client
|
||||
/// modes up front (see [`validate_dimensions`]).
|
||||
pub fn max_dimension(self) -> u32 {
|
||||
match self {
|
||||
Codec::H264 => 4096,
|
||||
// PyroWave has no codec-level dimension cap (arbitrary even sizes); 8192 matches the
|
||||
// buffer-math guard the other codecs get.
|
||||
Codec::H265 | Codec::Av1 | Codec::PyroWave => 8192,
|
||||
}
|
||||
}
|
||||
|
||||
/// The codec's *spec* top level/tier bitrate (bits/s) — the usual boundary at which NVENC
|
||||
/// starts rejecting `avcodec_open2` with EINVAL. NOT a hard cap: [`open_video`](crate::encode::
|
||||
/// open_video) probes the actual GPU ceiling by stepping DOWN from the requested bitrate only on
|
||||
/// EINVAL, and uses this purely as the first step-down candidate (so a card that accepts more —
|
||||
/// an RTX 5070 Ti does >1 Gbps HEVC where a 4090 caps at ~800 Mbps — is never clamped to it).
|
||||
/// HEVC Level 6.2 High tier = 800 Mbps; H.264 High level 6.2 ≈ 480 Mbps; AV1's levels allow more.
|
||||
pub fn max_bitrate_bps(self) -> u64 {
|
||||
match self {
|
||||
Codec::H264 => 480_000_000,
|
||||
Codec::H265 => 800_000_000,
|
||||
Codec::Av1 => 1_200_000_000,
|
||||
// No spec level/tier: the rate is a plain per-frame byte budget. Use the protocol's
|
||||
// own bitrate clamp so the step-down probe logic never binds below it.
|
||||
Codec::PyroWave => 8_000_000_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
|
||||
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
|
||||
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
|
||||
/// direct-NVENC paths (which used to hardwire 1 frame) to parity. Larger values let complex
|
||||
/// frames borrow bits — better rate utilization at the cost of per-frame size variance.
|
||||
pub(crate) fn vbv_frames_env() -> f64 {
|
||||
std::env::var("PUNKTFUNK_VBV_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
|
||||
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
|
||||
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
||||
/// `mode=WxHxFPS`, so this is the gate on attacker/typo-controlled dimensions.
|
||||
pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()> {
|
||||
if width == 0 || height == 0 {
|
||||
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be non-zero");
|
||||
}
|
||||
// NVENC requires even dimensions for the chroma subsampling it does internally.
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be even");
|
||||
}
|
||||
// PyroWave's 5-level wavelet decomposition needs ≥ 4·2⁵ px per axis (upstream
|
||||
// `MinimumImageSize` — the band mirroring breaks below it); reject a tiny mode here
|
||||
// (e.g. a match-window resize dragged to a sliver) instead of failing the encoder
|
||||
// rebuild after the switch was acked.
|
||||
if codec == Codec::PyroWave && (width < 128 || height < 128) {
|
||||
anyhow::bail!(
|
||||
"invalid PyroWave resolution {width}x{height}: the wavelet needs at least 128px per axis"
|
||||
);
|
||||
}
|
||||
let max = codec.max_dimension();
|
||||
if width > max || height > max {
|
||||
anyhow::bail!(
|
||||
"{codec:?} max dimension is {max}px; requested {width}x{height} \
|
||||
(use HEVC/AV1 above 4096, or lower the client resolution)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_and_odd_dimensions() {
|
||||
assert!(validate_dimensions(Codec::H265, 0, 1080).is_err());
|
||||
assert!(validate_dimensions(Codec::H265, 1920, 0).is_err());
|
||||
assert!(validate_dimensions(Codec::H265, 1921, 1080).is_err()); // odd width
|
||||
assert!(validate_dimensions(Codec::H265, 1920, 1081).is_err()); // odd height
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_capped_at_4096() {
|
||||
assert!(validate_dimensions(Codec::H264, 3840, 2160).is_ok()); // 4K fits (width < 4096)
|
||||
assert!(validate_dimensions(Codec::H264, 4096, 4096).is_ok()); // exactly at the limit
|
||||
assert!(validate_dimensions(Codec::H264, 4098, 2160).is_err());
|
||||
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_and_av1_allow_up_to_8192() {
|
||||
for c in [Codec::H265, Codec::Av1] {
|
||||
assert!(validate_dimensions(c, 3840, 2160).is_ok());
|
||||
assert!(validate_dimensions(c, 7680, 4320).is_ok()); // 8K fits
|
||||
assert!(validate_dimensions(c, 8192, 8192).is_ok());
|
||||
assert!(validate_dimensions(c, 8194, 4320).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_modes_accepted() {
|
||||
for c in [Codec::H264, Codec::H265, Codec::Av1] {
|
||||
for (w, h) in [(1280, 720), (1920, 1080), (2560, 1440)] {
|
||||
assert!(validate_dimensions(c, w, h).is_ok(), "{c:?} {w}x{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
|
||||
#[test]
|
||||
fn codec_wire_roundtrip_and_label() {
|
||||
for c in [Codec::H264, Codec::H265, Codec::Av1] {
|
||||
assert_eq!(Codec::from_wire(c.to_wire()), c);
|
||||
}
|
||||
assert_eq!(Codec::H264.label(), "h264");
|
||||
assert_eq!(Codec::H265.label(), "hevc");
|
||||
assert_eq!(Codec::Av1.label(), "av1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Shared libavcodec (FFmpeg) glue for the three libav encode backends — Linux NVENC
|
||||
//! (`encode/linux/mod.rs`), VAAPI (`encode/linux/vaapi.rs`), and Windows AMF/QSV
|
||||
//! (`encode/windows/ffmpeg_win.rs`) — so the byte-identical pieces live once (plan §2.2, the Tier-2
|
||||
//! gap). Free functions and consts over borrowed handles; nothing here is per-frame `dyn`,
|
||||
//! allocating, or on the zero-copy ingest path.
|
||||
use crate::encode::EncodedFrame;
|
||||
use anyhow::{Context, Result};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use ffmpeg_next::ffi; // = ffmpeg_sys_next
|
||||
use ffmpeg_next::format::Pixel;
|
||||
use ffmpeg_next::{encoder, Packet, Rational};
|
||||
use std::os::raw::c_int;
|
||||
|
||||
/// swscale: nearest-neighbour scaler flag (`SWS_POINT`). We never rescale (src dims == dst dims), so
|
||||
/// the resampler choice only governs the colour-conversion path; POINT is the cheapest.
|
||||
pub(crate) const SWS_POINT: c_int = 0x10;
|
||||
/// swscale colorspace id for ITU-R BT.709 (`SWS_CS_ITU709`) — the CSC coefficients for our RGB→YUV.
|
||||
pub(crate) const SWS_CS_ITU709: c_int = 1;
|
||||
/// swscale colorspace id for ITU-R BT.2020 non-constant-luminance (`SWS_CS_BT2020`) — the CSC
|
||||
/// coefficients for the HDR X2BGR10→P010 path (Windows only today).
|
||||
pub(crate) const SWS_CS_BT2020: c_int = 9;
|
||||
|
||||
/// `Pixel` → `AVPixelFormat`. `Pixel` is `#[repr(i32)]`-compatible with `AVPixelFormat` (the bindgen
|
||||
/// enum) via this documented conversion in ffmpeg-next.
|
||||
pub(crate) fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
|
||||
ffi::AVPixelFormat::from(p)
|
||||
}
|
||||
|
||||
/// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can
|
||||
/// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse
|
||||
/// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop.
|
||||
pub(crate) enum PollOutcome {
|
||||
Packet(EncodedFrame),
|
||||
Again,
|
||||
Eof,
|
||||
}
|
||||
|
||||
/// Apply the shared low-latency rate-control contract to a **not-yet-opened** encoder context: a
|
||||
/// fixed frame rate, CBR (target == max bitrate), B-frames off, and a tight ~1-frame VBV/HRD buffer.
|
||||
///
|
||||
/// The VBV size bounds any single frame. Under CBR with no buffer set, libav's encoders use a loose
|
||||
/// default VBV, so a high-motion P-frame can balloon to many times the average; those extra packets
|
||||
/// overflow the bounded send queue + kernel socket buffer and get dropped, which the client sees as
|
||||
/// framedrops/jitter (and, on the infinite-GOP path, as old/stale frames flashing until the next
|
||||
/// RFI). A tight ~1-frame buffer makes the encoder hold frame size roughly constant and absorb motion
|
||||
/// as a momentary QP (quality) dip instead — the trade we want. Default = 1 frame of bits
|
||||
/// (bitrate/fps); `PUNKTFUNK_VBV_FRAMES` tunes it (larger = better motion quality, bigger bursts).
|
||||
///
|
||||
/// The caller still owns `set_format` (pixel format) and `gop_size` (GOP policy differs: NVENC's
|
||||
/// infinite/intra-refresh wave vs the VAAPI/AMF `i32::MAX`), since those are backend-specific.
|
||||
pub(crate) fn apply_low_latency_rc(video: &mut encoder::video::Video, fps: u32, bitrate_bps: u64) {
|
||||
video.set_time_base(Rational(1, fps as i32));
|
||||
video.set_frame_rate(Some(Rational(fps as i32, 1)));
|
||||
video.set_bit_rate(bitrate_bps as usize);
|
||||
video.set_max_bit_rate(bitrate_bps as usize);
|
||||
video.set_max_b_frames(0);
|
||||
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
||||
.clamp(1.0, i32::MAX as f64);
|
||||
// SAFETY: `video` wraps a freshly-allocated `AVCodecContext` we hold by value and have not opened
|
||||
// yet; `as_mut_ptr()` returns that non-null, aligned, exclusively-owned context. Writing the plain
|
||||
// `rc_buffer_size` int before `open_with` is the supported way to set a field ffmpeg-next exposes
|
||||
// no setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
|
||||
unsafe {
|
||||
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the encoder for one packet (shared across the NVENC/VAAPI/AMF/QSV libav backends). The
|
||||
/// `EncodedFrame`'s only allocation is the `to_vec()` of the bitstream — the same copy each backend
|
||||
/// already made — so this stays off any per-frame `dyn`/`Box`/channel path.
|
||||
pub(crate) fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<PollOutcome> {
|
||||
let mut pkt = Packet::empty();
|
||||
match enc.receive_packet(&mut pkt) {
|
||||
Ok(()) => {
|
||||
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
|
||||
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
|
||||
Ok(PollOutcome::Packet(EncodedFrame {
|
||||
data,
|
||||
pts_ns: pts * 1_000_000_000 / fps as u64,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
chunk_aligned: false,
|
||||
}))
|
||||
}
|
||||
// No packet ready yet (need another input frame).
|
||||
Err(ffmpeg::Error::Other { errno })
|
||||
if errno == ffmpeg::util::error::EAGAIN
|
||||
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
|
||||
{
|
||||
Ok(PollOutcome::Again)
|
||||
}
|
||||
// Fully drained after flush().
|
||||
Err(ffmpeg::Error::Eof) => Ok(PollOutcome::Eof),
|
||||
Err(e) => Err(e).context("receive_packet"),
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,16 @@ use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ffmpeg::format::Pixel;
|
||||
use ffmpeg::util::frame::Video as VideoFrame;
|
||||
use ffmpeg::{codec, encoder, Dictionary, Packet, Rational};
|
||||
use ffmpeg::{codec, encoder, Dictionary};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::os::raw::c_int;
|
||||
use std::ptr;
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
/// swscale: nearest-neighbour scaler flag (`SWS_POINT`). We never rescale (src dims == dst dims), so
|
||||
/// the resampler choice only governs the colour-conversion path; POINT is the cheapest.
|
||||
const SWS_POINT: c_int = 0x10;
|
||||
/// swscale colorspace id for ITU-R BT.709 (`SWS_CS_ITU709`) — the CSC coefficients for our RGB→YUV.
|
||||
const SWS_CS_ITU709: c_int = 1;
|
||||
|
||||
/// The swscale *source* pixel format for a captured packed RGB/BGR layout (the real byte order, not
|
||||
/// the NVENC-padded `*0` form). Used by the 4:4:4 RGB→YUV444P conversion path. Mirrors the VAAPI
|
||||
/// CPU-input mapping; YUV/10-bit inputs can't feed this path (the 4:4:4 session forces packed RGB).
|
||||
@@ -118,13 +115,6 @@ impl Drop for CudaHw {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ffmpeg::format::Pixel` → raw `AVPixelFormat`.
|
||||
fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
|
||||
// `Pixel` is `#[repr(i32)]`-compatible with `AVPixelFormat` (the bindgen enum) via this
|
||||
// documented conversion in ffmpeg-next.
|
||||
ffi::AVPixelFormat::from(p)
|
||||
}
|
||||
|
||||
/// Map a captured layout to the NVENC input pixel format, and whether a 3→4 byte expand is
|
||||
/// needed (packed RGB/BGR have no padding byte; the NVENC `*0` formats do).
|
||||
fn nvenc_input(format: PixelFormat) -> (Pixel, bool) {
|
||||
@@ -299,34 +289,8 @@ impl NvencEncoder {
|
||||
video.set_width(width);
|
||||
video.set_height(height);
|
||||
video.set_format(nvenc_pixel); // NVENC converts RGB→YUV internally
|
||||
video.set_time_base(Rational(1, fps as i32));
|
||||
video.set_frame_rate(Some(Rational(fps as i32, 1)));
|
||||
video.set_bit_rate(bitrate_bps as usize);
|
||||
video.set_max_bit_rate(bitrate_bps as usize);
|
||||
// VBV/HRD buffer — bound the SIZE of any single frame. Under CBR with no buffer set, NVENC
|
||||
// uses a loose default VBV, so a high-motion P-frame is allowed to balloon to many times the
|
||||
// average; those extra packets overflow the bounded send queue + kernel socket buffer and
|
||||
// get dropped, which the client sees as framedrops/jitter (and, on the infinite-GOP path, as
|
||||
// old/stale frames flashing until the next RFI). A tight ~1-frame buffer makes the encoder
|
||||
// hold frame size roughly constant and absorb motion as a momentary QP (quality) dip instead
|
||||
// — the trade we want. Default = 1 frame of bits (bitrate/fps); PUNKTFUNK_VBV_FRAMES tunes it
|
||||
// (larger = better motion quality but bigger per-frame bursts).
|
||||
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f32>().ok())
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.unwrap_or(1.0);
|
||||
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
|
||||
.clamp(1.0, i32::MAX as f64);
|
||||
// SAFETY: `video` is the ffmpeg-next encoder builder wrapping a freshly-allocated
|
||||
// `AVCodecContext` that we hold by value and have not opened yet; `video.as_mut_ptr()` returns
|
||||
// that non-null, properly-aligned, exclusively-owned context. Writing the plain `rc_buffer_size`
|
||||
// int field before `open_with` is the supported way to set a field ffmpeg-next exposes no
|
||||
// setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
|
||||
unsafe {
|
||||
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
|
||||
}
|
||||
video.set_max_b_frames(0);
|
||||
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||
// Infinite GOP — NO periodic IDR. A keyframe at 5120x1440 is ~20-40x a P-frame, so a
|
||||
// periodic IDR is a recurring multi-millisecond encode+packetize+send spike — the ~2s
|
||||
// "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below)
|
||||
@@ -641,30 +605,11 @@ impl Encoder for NvencEncoder {
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
let mut pkt = Packet::empty();
|
||||
match self.enc.receive_packet(&mut pkt) {
|
||||
Ok(()) => {
|
||||
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
|
||||
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
|
||||
let pts_ns = pts * 1_000_000_000 / self.fps as u64;
|
||||
Ok(Some(EncodedFrame {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
chunk_aligned: false,
|
||||
}))
|
||||
}
|
||||
// No packet ready yet (need another input frame).
|
||||
Err(ffmpeg::Error::Other { errno })
|
||||
if errno == ffmpeg::util::error::EAGAIN
|
||||
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
// Fully drained after flush().
|
||||
Err(ffmpeg::Error::Eof) => Ok(None),
|
||||
Err(e) => Err(e).context("receive_packet"),
|
||||
// Non-blocking single drain: a packet ships, EAGAIN (need another input frame) and EOF
|
||||
// (drained after flush) both mean "nothing this tick".
|
||||
match poll_encoder(&mut self.enc, self.fps)? {
|
||||
PollOutcome::Packet(au) => Ok(Some(au)),
|
||||
PollOutcome::Again | PollOutcome::Eof => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use crate::capture::{CapturedFrame, FramePayload};
|
||||
use crate::zerocopy::cuda::{self, InputSurface};
|
||||
@@ -105,20 +109,6 @@ struct EncodeApi {
|
||||
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
|
||||
}
|
||||
|
||||
/// Local `NVENCSTATUS` → `Result` (the sdk's own helper lives in the `safe` module this file must
|
||||
/// not pull in). The raw status's Debug repr is the error payload.
|
||||
trait NvStatusExt {
|
||||
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS>;
|
||||
}
|
||||
impl NvStatusExt for nv::NVENCSTATUS {
|
||||
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS> {
|
||||
match self {
|
||||
nv::NVENCSTATUS::NV_ENC_SUCCESS => Ok(()),
|
||||
err => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable (no NVIDIA driver/.so,
|
||||
/// or a driver older than our headers) — [`NvencCudaEncoder::open`] gates on it and the VAAPI/
|
||||
/// software backends carry on.
|
||||
@@ -129,7 +119,7 @@ fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
|
||||
.get_or_init(|| {
|
||||
let table = load_api();
|
||||
if let Err(e) = &table {
|
||||
tracing::warn!("NVENC (Linux direct) API unavailable: {e}");
|
||||
tracing::warn!(error = %e, "NVENC (Linux direct) API unavailable");
|
||||
}
|
||||
table
|
||||
})
|
||||
@@ -220,21 +210,6 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||
/// helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
|
||||
const POOL: usize = 8;
|
||||
|
||||
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
|
||||
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
|
||||
/// P-frame single-reference for low latency.
|
||||
const RFI_DPB: u32 = 5;
|
||||
|
||||
fn codec_guid(codec: Codec) -> nv::GUID {
|
||||
match codec {
|
||||
Codec::H264 => nv::NV_ENC_CODEC_H264_GUID,
|
||||
Codec::H265 => nv::NV_ENC_CODEC_HEVC_GUID,
|
||||
Codec::Av1 => nv::NV_ENC_CODEC_AV1_GUID,
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches NVENC.
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero-
|
||||
/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal
|
||||
/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit.
|
||||
@@ -317,6 +292,14 @@ pub struct NvencCudaEncoder {
|
||||
cursor: Option<cuda::CursorBlend>,
|
||||
cursor_tried: bool,
|
||||
cursor_serial: u64,
|
||||
/// Suppress-until-success latches for the per-frame cursor upload/blend warns: a persistent
|
||||
/// failure sits in the submit() hot path, so warn once per failure streak (reset on success)
|
||||
/// rather than on every cursor-bearing frame, which would evict the log ring.
|
||||
cursor_upload_warned: bool,
|
||||
cursor_blend_warned: bool,
|
||||
/// One-shot latch for [`diagnose_failed_open`](Self::diagnose_failed_open) so a rebuild-retry
|
||||
/// burst (the session loop's bounded encoder resets) logs the diagnosis once, not per attempt.
|
||||
diagnosed: bool,
|
||||
}
|
||||
|
||||
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
||||
@@ -382,6 +365,9 @@ impl NvencCudaEncoder {
|
||||
cursor: None,
|
||||
cursor_tried: false,
|
||||
cursor_serial: u64::MAX,
|
||||
cursor_upload_warned: false,
|
||||
cursor_blend_warned: false,
|
||||
diagnosed: false,
|
||||
inited: false,
|
||||
rfi_supported: false,
|
||||
custom_vbv: false,
|
||||
@@ -407,7 +393,16 @@ impl NvencCudaEncoder {
|
||||
for &bs in &self.bitstreams {
|
||||
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
|
||||
}
|
||||
let _ = (api().destroy_encoder)(self.encoder);
|
||||
// A destroy failure means the driver may still hold this session's slot (the concurrent-
|
||||
// session cap is per process and only a restart clears a leak) — make it visible instead
|
||||
// of silently discarding the status.
|
||||
if let Err(e) = (api().destroy_encoder)(self.encoder).nv_ok() {
|
||||
tracing::warn!(
|
||||
status = ?e,
|
||||
"NVENC destroy_encoder failed at teardown — the driver may have leaked this \
|
||||
session's slot toward the concurrent-session cap"
|
||||
);
|
||||
}
|
||||
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
|
||||
self.bitstreams.clear();
|
||||
self.pending.clear();
|
||||
@@ -446,11 +441,19 @@ impl NvencCudaEncoder {
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
||||
.nv_ok()
|
||||
.map_err(|e| {
|
||||
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
|
||||
})?;
|
||||
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
|
||||
// The NVENC docs require NvEncDestroyEncoder even after a FAILED open (the driver may
|
||||
// have allocated the session slot before erroring) — without it, every failed open in
|
||||
// a retry loop leaks a slot toward the concurrent-session cap, turning a transient
|
||||
// failure into permanent exhaustion that only a host restart clears.
|
||||
if !enc.is_null() {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
}
|
||||
return Err(nvenc_status::call_err(
|
||||
"open_encode_session_ex (caps probe)",
|
||||
e,
|
||||
));
|
||||
}
|
||||
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
|
||||
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
|
||||
let yuv444 = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE);
|
||||
@@ -490,6 +493,62 @@ impl NvencCudaEncoder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-shot self-diagnosis for a failed session open (2026-07 field report: after a codec
|
||||
/// switch every open returned `NV_ENC_ERR_INVALID_VERSION` until the HOST PROCESS was
|
||||
/// restarted — so the poisoned state is per-process, not the driver install). Retries the raw
|
||||
/// open on a FRESH dedicated CUDA context to split the candidate causes apart in the log:
|
||||
/// * fresh context WORKS → the shared process context (or its NVENC association) is in a
|
||||
/// bad state — a host bug to report;
|
||||
/// * fresh context fails the SAME way → driver-level: userspace/kernel version skew,
|
||||
/// concurrent-session-cap exhaustion (leaked sessions), or a lost/reset GPU;
|
||||
/// * no fresh context AT ALL → CUDA itself is unhealthy in this process.
|
||||
///
|
||||
/// Log-only (the caller still fails the open); latched per encoder so a reset burst logs once.
|
||||
fn diagnose_failed_open(&mut self) {
|
||||
if self.diagnosed {
|
||||
return;
|
||||
}
|
||||
self.diagnosed = true;
|
||||
let fresh = cuda::with_fresh_context(|ctx| {
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
|
||||
device: ctx,
|
||||
apiVersion: nv::NVENCAPI_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
// SAFETY: `params`/`enc` are live stack locals across the synchronous call; `ctx` is
|
||||
// the live diagnostic context `with_fresh_context` just created. Any session the probe
|
||||
// opened (even on a failed status, per the NVENC docs) is destroyed exactly once here.
|
||||
unsafe {
|
||||
let st = (api().open_encode_session_ex)(&mut params, &mut enc);
|
||||
if !enc.is_null() {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
}
|
||||
st
|
||||
}
|
||||
});
|
||||
match fresh {
|
||||
Ok(nv::NVENCSTATUS::NV_ENC_SUCCESS) => tracing::error!(
|
||||
"NVENC self-diagnosis: the session opens FINE on a fresh CUDA context — the \
|
||||
host's shared CUDA context is in a bad state (host bug; please report this log)"
|
||||
),
|
||||
Ok(st) => tracing::error!(
|
||||
fresh_ctx_status = ?st,
|
||||
"NVENC self-diagnosis: the open fails on a fresh CUDA context too — driver-level \
|
||||
cause: {}",
|
||||
nvenc_status::explain(st)
|
||||
),
|
||||
Err(e) => tracing::error!(
|
||||
error = %format!("{e:#}"),
|
||||
"NVENC self-diagnosis: could not create a fresh CUDA context — CUDA itself is \
|
||||
unhealthy in this process (GPU reset/fell off the bus, or a poisoned driver \
|
||||
state); a host restart should clear it"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Author the session's `NV_ENC_CONFIG` at `bitrate` (bps): the P1/ULL preset (queried on
|
||||
/// `enc`) seeded with the RC/tier/chroma/VUI/DPB shape this backend always runs. ONE builder
|
||||
/// shared by [`try_open_session`] and [`Encoder::reconfigure_bitrate`], so an in-place rate
|
||||
@@ -512,164 +571,34 @@ impl NvencCudaEncoder {
|
||||
&mut preset,
|
||||
)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("get_encode_preset_config_ex: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("get_encode_preset_config_ex", e))?;
|
||||
let mut cfg = preset.presetCfg;
|
||||
|
||||
// CBR, infinite GOP, P-only, ~1-frame VBV (mirror the Windows/Linux-libav RC config).
|
||||
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
|
||||
cfg.frameIntervalP = 1;
|
||||
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
|
||||
let bps = bitrate.min(u32::MAX as u64) as u32;
|
||||
cfg.rcParams.averageBitRate = bps;
|
||||
cfg.rcParams.maxBitRate = bps;
|
||||
if self.custom_vbv {
|
||||
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
|
||||
let vbv = ((bitrate as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
||||
.clamp(1.0, u32::MAX as f64) as u32;
|
||||
cfg.rcParams.vbvBufferSize = vbv;
|
||||
cfg.rcParams.vbvInitialDelay = vbv;
|
||||
}
|
||||
|
||||
// Tier + autoselect level, PER CODEC (HEVC HIGH tier for the higher bitrate ceiling; AV1
|
||||
// Main tier only — tier=1 fails AV1 init; H.264 has no tier). Level 0 = autoselect.
|
||||
match self.codec {
|
||||
Codec::H265 => {
|
||||
cfg.encodeCodecConfig.hevcConfig.tier = 1;
|
||||
cfg.encodeCodecConfig.hevcConfig.level = 0;
|
||||
}
|
||||
Codec::Av1 => {}
|
||||
Codec::H264 => {}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
|
||||
// Chroma + bit depth. 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3) engages on a YUV444
|
||||
// input; 10-bit Main10 is ported but never engages on Linux yet (input is 8-bit).
|
||||
// Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
|
||||
// low-latency contract. On Linux the full-chroma input is a YUV444 surface and the input is
|
||||
// 8-bit today, so AV1's input-depth is 0.
|
||||
let yuv444_input = matches!(
|
||||
self.buffer_fmt,
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
|
||||
);
|
||||
if self.chroma_444 && yuv444_input {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
||||
if self.bit_depth == 10 {
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
|
||||
}
|
||||
} else if self.bit_depth == 10 {
|
||||
match self.codec {
|
||||
Codec::H265 => {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
|
||||
}
|
||||
Codec::Av1 => {
|
||||
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
|
||||
cfg.encodeCodecConfig
|
||||
.av1Config
|
||||
.set_inputPixelBitDepthMinus8(0);
|
||||
}
|
||||
Codec::H264 => {}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
// Colour signaling, written UNCONDITIONALLY: the CUDA input is already CSC'd to a specific
|
||||
// matrix (NV12/YUV444 8-bit BT.709 limited from the convert worker, or the packed-RGB path
|
||||
// where NVENC's internal CSC follows this VUI), so the stream must say BT.709 or a decoder
|
||||
// whose "unspecified" default is 601 will mis-render.
|
||||
{
|
||||
let (prim, trc, mat) = if self.hdr {
|
||||
(
|
||||
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020,
|
||||
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084,
|
||||
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT709,
|
||||
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709,
|
||||
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT709,
|
||||
)
|
||||
};
|
||||
match self.codec {
|
||||
Codec::H265 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = prim;
|
||||
vui.transferCharacteristics = trc;
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::H264 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = prim;
|
||||
vui.transferCharacteristics = trc;
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
let av1 = &mut cfg.encodeCodecConfig.av1Config;
|
||||
av1.colorPrimaries = prim;
|
||||
av1.transferCharacteristics = trc;
|
||||
av1.matrixCoefficients = mat;
|
||||
av1.colorRange = 0;
|
||||
}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
// Reference-frame invalidation: a deeper DPB so an invalidated reference can fall back to an
|
||||
// older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each P-frame
|
||||
// single-reference for low latency. Only when this GPU supports RFI.
|
||||
if self.rfi_supported {
|
||||
let one = nv::NV_ENC_NUM_REF_FRAMES::NV_ENC_NUM_REF_FRAMES_1;
|
||||
match self.codec {
|
||||
Codec::H264 => {
|
||||
cfg.encodeCodecConfig.h264Config.maxNumRefFrames = RFI_DPB;
|
||||
cfg.encodeCodecConfig.h264Config.numRefL0 = one;
|
||||
}
|
||||
Codec::H265 => {
|
||||
cfg.encodeCodecConfig.hevcConfig.maxNumRefFramesInDPB = RFI_DPB;
|
||||
cfg.encodeCodecConfig.hevcConfig.numRefL0 = one;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
cfg.encodeCodecConfig.av1Config.maxNumRefFramesInDPB = RFI_DPB;
|
||||
}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
apply_low_latency_config(
|
||||
&mut cfg,
|
||||
LowLatencyConfig {
|
||||
codec: self.codec,
|
||||
bitrate,
|
||||
fps: self.fps,
|
||||
custom_vbv: self.custom_vbv,
|
||||
chroma_444: self.chroma_444,
|
||||
full_chroma_input: yuv444_input,
|
||||
bit_depth: self.bit_depth,
|
||||
av1_input_depth_minus8: 0,
|
||||
hdr: self.hdr,
|
||||
rfi_supported: self.rfi_supported,
|
||||
},
|
||||
);
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Author the `NV_ENC_INITIALIZE_PARAMS` pointing at `cfg`. Shared by [`try_open_session`]
|
||||
/// and [`Encoder::reconfigure_bitrate`] — a reconfigure must present the SAME init params as
|
||||
/// the open. The returned struct borrows `cfg` raw; the caller keeps `cfg` alive across the
|
||||
/// NVENC call it feeds this into.
|
||||
fn build_init_params(
|
||||
&self,
|
||||
cfg: &mut nv::NV_ENC_CONFIG,
|
||||
split_mode: u32,
|
||||
) -> nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
||||
encodeGUID: self.codec_guid,
|
||||
presetGUID: nv::NV_ENC_PRESET_P1_GUID,
|
||||
tuningInfo: nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
|
||||
encodeWidth: self.width,
|
||||
encodeHeight: self.height,
|
||||
darWidth: self.width,
|
||||
darHeight: self.height,
|
||||
frameRateNum: self.fps,
|
||||
frameRateDen: 1,
|
||||
enablePTD: 1,
|
||||
encodeConfig: cfg,
|
||||
..Default::default()
|
||||
};
|
||||
init.set_splitEncodeMode(split_mode);
|
||||
init
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
|
||||
/// Returns the session handle, or destroys it and returns the error.
|
||||
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
|
||||
@@ -681,9 +610,14 @@ impl NvencCudaEncoder {
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
|
||||
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
|
||||
// Destroy-on-failed-open, as in `query_caps`: a failed open may still hold a session
|
||||
// slot that must be released.
|
||||
if !enc.is_null() {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
}
|
||||
return Err(nvenc_status::call_err("open_encode_session_ex", e));
|
||||
}
|
||||
|
||||
let mut cfg = match self.build_config(enc, bitrate) {
|
||||
Ok(cfg) => cfg,
|
||||
@@ -692,13 +626,21 @@ impl NvencCudaEncoder {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let mut init = self.build_init_params(&mut cfg, split_mode);
|
||||
let mut init = build_init_params(
|
||||
self.codec_guid,
|
||||
self.width,
|
||||
self.height,
|
||||
self.fps,
|
||||
&mut cfg,
|
||||
split_mode,
|
||||
false,
|
||||
);
|
||||
|
||||
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||
Ok(()) => Ok(enc),
|
||||
Err(e) => {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
Err(anyhow!("initialize_encoder: {e:?}"))
|
||||
Err(nvenc_status::call_err("initialize_encoder", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -718,7 +660,12 @@ impl NvencCudaEncoder {
|
||||
self.cu_ctx = cuda::context().context("shared CUDA context (Linux direct NVENC)")?;
|
||||
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
|
||||
|
||||
self.query_caps()?;
|
||||
if let Err(e) = self.query_caps() {
|
||||
// The one place every session-open failure funnels through (the probe is the first
|
||||
// open of any session) — run the one-shot self-diagnosis before propagating.
|
||||
self.diagnose_failed_open();
|
||||
return Err(e);
|
||||
}
|
||||
const FLOOR_BPS: u64 = 10_000_000;
|
||||
let requested_bps = self.bitrate_bps;
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override
|
||||
@@ -818,7 +765,7 @@ impl NvencCudaEncoder {
|
||||
};
|
||||
(api().create_bitstream_buffer)(enc, &mut cb)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("create_bitstream_buffer", e))?;
|
||||
self.bitstreams.push(cb.bitstreamBuffer);
|
||||
}
|
||||
|
||||
@@ -849,7 +796,7 @@ impl NvencCudaEncoder {
|
||||
};
|
||||
(api().register_resource)(self.encoder, &mut rr)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("register_resource (CUDADEVICEPTR): {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("register_resource (CUDADEVICEPTR)", e))?;
|
||||
self.ring.push(RingSlot {
|
||||
surface,
|
||||
reg: rr.registeredResource,
|
||||
@@ -858,14 +805,12 @@ impl NvencCudaEncoder {
|
||||
|
||||
self.inited = true;
|
||||
tracing::info!(
|
||||
"NVENC CUDA session: {}x{}@{} {}-bit {} Mbps {:?} fmt={:?}",
|
||||
self.width,
|
||||
self.height,
|
||||
self.fps,
|
||||
self.bit_depth,
|
||||
self.bitrate_bps / 1_000_000,
|
||||
self.codec_guid,
|
||||
self.buffer_fmt,
|
||||
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
||||
bit_depth = self.bit_depth,
|
||||
mbps = self.bitrate_bps / 1_000_000,
|
||||
codec = ?self.codec_guid,
|
||||
fmt = ?self.buffer_fmt,
|
||||
"NVENC CUDA session ready"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -970,9 +915,19 @@ impl Encoder for NvencCudaEncoder {
|
||||
if let Some(cb) = &self.cursor {
|
||||
if self.cursor_serial != ov.serial {
|
||||
match cb.upload(ov.rgba.as_slice(), ov.w, ov.h) {
|
||||
Ok(()) => self.cursor_serial = ov.serial,
|
||||
Ok(()) => {
|
||||
self.cursor_serial = ov.serial;
|
||||
self.cursor_upload_warned = false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "cursor upload failed")
|
||||
if !self.cursor_upload_warned {
|
||||
self.cursor_upload_warned = true;
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
serial = ov.serial,
|
||||
"NVENC (Linux): cursor upload failed — cursor not composited"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -991,7 +946,15 @@ impl Encoder for NvencCudaEncoder {
|
||||
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
tracing::warn!(error = %format!("{e:#}"), "cursor blend launch failed");
|
||||
if !self.cursor_blend_warned {
|
||||
self.cursor_blend_warned = true;
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"NVENC (Linux): cursor blend launch failed — cursor not composited"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.cursor_blend_warned = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1014,7 +977,7 @@ impl Encoder for NvencCudaEncoder {
|
||||
};
|
||||
(api().map_input_resource)(self.encoder, &mut mp)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("map_input_resource: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("map_input_resource", e))?;
|
||||
|
||||
let pts = self.frame_idx as u64;
|
||||
self.frame_idx += 1;
|
||||
@@ -1086,7 +1049,7 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
(api().encode_picture)(self.encoder, &mut pic)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
|
||||
self.pending.push_back((
|
||||
self.bitstreams[slot],
|
||||
mp.mappedResource,
|
||||
@@ -1186,7 +1149,7 @@ impl Encoder for NvencCudaEncoder {
|
||||
};
|
||||
(api().lock_bitstream)(self.encoder, &mut lock)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("lock_bitstream: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("lock_bitstream", e))?;
|
||||
let data = std::slice::from_raw_parts(
|
||||
lock.bitstreamBufferPtr as *const u8,
|
||||
lock.bitstreamSizeInBytes as usize,
|
||||
@@ -1198,7 +1161,7 @@ impl Encoder for NvencCudaEncoder {
|
||||
);
|
||||
(api().unlock_bitstream)(self.encoder, bs)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("unlock_bitstream", e))?;
|
||||
if !map.is_null() {
|
||||
let _ = (api().unmap_input_resource)(self.encoder, map);
|
||||
}
|
||||
@@ -1242,7 +1205,15 @@ impl Encoder for NvencCudaEncoder {
|
||||
};
|
||||
let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
|
||||
version: nv::NV_ENC_RECONFIGURE_PARAMS_VER,
|
||||
reInitEncodeParams: self.build_init_params(&mut cfg, self.split_mode),
|
||||
reInitEncodeParams: build_init_params(
|
||||
self.codec_guid,
|
||||
self.width,
|
||||
self.height,
|
||||
self.fps,
|
||||
&mut cfg,
|
||||
self.split_mode,
|
||||
false,
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
// Keep the encoder's RC state and reference chain: no reset, no IDR — the in-flight
|
||||
@@ -1514,4 +1485,161 @@ mod tests {
|
||||
"negative first → decline"
|
||||
);
|
||||
}
|
||||
|
||||
fn open_h265() -> NvencCudaEncoder {
|
||||
NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
1280,
|
||||
720,
|
||||
60,
|
||||
20_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open NVENC CUDA encoder")
|
||||
}
|
||||
|
||||
/// ON-HARDWARE: the codec-switch lifecycle from the 2026-07 field report ("switching the
|
||||
/// codec leaves the host unable to bring the encoder up until a restart") — cycle sessions
|
||||
/// across codecs in ONE process, clean drain per leg. Every leg must open and encode. Run:
|
||||
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_cuda_codec_switch --nocapture
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_codec_switch_reopen() {
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
for (leg, codec) in [
|
||||
Codec::H265,
|
||||
Codec::Av1,
|
||||
Codec::H265,
|
||||
Codec::H264,
|
||||
Codec::H265,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
codec,
|
||||
PixelFormat::Nv12,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
20_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open");
|
||||
for f in 0..4u32 {
|
||||
let frame = nv12_frame(W, H, f);
|
||||
enc.submit_indexed(&frame, f)
|
||||
.unwrap_or_else(|e| panic!("leg {leg} {codec:?} submit failed: {e:#}"));
|
||||
while enc.poll().expect("poll").is_some() {}
|
||||
}
|
||||
drop(enc);
|
||||
}
|
||||
println!("nvenc_cuda codec-switch: 5 legs across H265/AV1/H264, all clean");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE: dirty teardown — drop encoders with encodes still in flight (what a
|
||||
/// mid-stream session kill does), several times, then a fresh session must still open. Guards
|
||||
/// the teardown-with-pending path against driver-side session-slot leaks.
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_dirty_teardown_reopen() {
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
for round in 0..3 {
|
||||
let mut enc = open_h265();
|
||||
for f in 0..4u32 {
|
||||
let frame = nv12_frame(W, H, f);
|
||||
enc.submit_indexed(&frame, f)
|
||||
.unwrap_or_else(|e| panic!("round {round} submit {f} failed: {e:#}"));
|
||||
}
|
||||
drop(enc); // teardown with 4 in-flight encodes
|
||||
}
|
||||
let mut enc = open_h265();
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0)
|
||||
.expect("reopen after dirty teardowns");
|
||||
while enc.poll().expect("poll").is_some() {}
|
||||
println!("nvenc_cuda dirty-teardown: 3 dirty drops, reopen clean");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE: the session-open failure path end to end — exhaust the driver's concurrent-
|
||||
/// session cap with raw opens, assert a real encoder open fails with the actionable error
|
||||
/// (and fires the one-shot self-diagnosis), then free the slots and assert the SAME encoder
|
||||
/// rebuilds in place and produces an AU. This is the transient the session loop's rebuild
|
||||
/// backoff is sized to outlive; on the RTX 5070 Ti (driver 610.43.03) the cap is 12 sessions
|
||||
/// and the failure status is `NV_ENC_ERR_INCOMPATIBLE_CLIENT_KEY`.
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_open_failure_diagnosis_and_recovery() {
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
try_api().expect("nvenc api");
|
||||
let shared = cuda::context().expect("shared ctx");
|
||||
|
||||
let open_raw = |device: *mut c_void| -> (nv::NVENCSTATUS, *mut c_void) {
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
|
||||
device,
|
||||
apiVersion: nv::NVENCAPI_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
// SAFETY: live params/out-param across the synchronous call; test-only.
|
||||
let st = unsafe { (api().open_encode_session_ex)(&mut params, &mut enc) };
|
||||
(st, enc)
|
||||
};
|
||||
|
||||
// Exhaust the concurrent-session cap.
|
||||
let mut held = Vec::new();
|
||||
loop {
|
||||
let (st, enc) = open_raw(shared);
|
||||
if st != nv::NVENCSTATUS::NV_ENC_SUCCESS {
|
||||
if !enc.is_null() {
|
||||
// SAFETY: destroy the failed-open residue per the NVENC docs.
|
||||
unsafe {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
held.push(enc);
|
||||
}
|
||||
assert!(!held.is_empty(), "expected a finite session cap");
|
||||
|
||||
// A real encoder open must now fail (lazy init → caps probe) with the actionable error.
|
||||
let mut enc = open_h265();
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
let err = enc
|
||||
.submit_indexed(&frame, 0)
|
||||
.expect_err("submit must fail while the cap is exhausted");
|
||||
println!("at-cap error (self-diagnosis logged alongside): {err:#}");
|
||||
|
||||
// The transient clears (slots freed) → the SAME encoder rebuilds in place and encodes.
|
||||
for e in held {
|
||||
// SAFETY: e came from a successful raw open above; destroyed exactly once.
|
||||
unsafe {
|
||||
let _ = (api().destroy_encoder)(e);
|
||||
}
|
||||
}
|
||||
assert!(enc.reset(), "in-place reset must be available");
|
||||
let frame = nv12_frame(W, H, 1);
|
||||
enc.submit_indexed(&frame, 1)
|
||||
.expect("rebuild after the transient cleared");
|
||||
let mut got = false;
|
||||
while enc.poll().expect("poll").is_some() {
|
||||
got = true;
|
||||
}
|
||||
assert!(got, "recovered encoder must produce an AU");
|
||||
println!("nvenc_cuda open-failure recovery: cap hit → diagnosed → recovered in place");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1211,9 +1211,9 @@ impl Encoder for PyroWaveEncoder {
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
};
|
||||
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||
if pw::pyrowave_encoder_create(&einfo, &mut enc) != pw::pyrowave_result_PYROWAVE_SUCCESS
|
||||
{
|
||||
tracing::error!("pyrowave: encoder rebuild failed");
|
||||
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
|
||||
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
|
||||
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
|
||||
return false;
|
||||
}
|
||||
self.pw_enc = enc;
|
||||
@@ -1228,7 +1228,7 @@ impl Encoder for PyroWaveEncoder {
|
||||
// (plan §4.6 — wavelet quality collapses well above the AIMD floor); until then this
|
||||
// faithfully applies whatever the caller asks.
|
||||
self.frame_budget = budget_for(bps, self.fps);
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
mbps = bps / 1_000_000,
|
||||
budget_kib = self.frame_budget / 1024,
|
||||
"pyrowave: per-frame rate budget retargeted in place"
|
||||
|
||||
@@ -26,7 +26,7 @@ use super::{Codec, EncodedFrame, Encoder};
|
||||
use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ffmpeg::format::Pixel;
|
||||
use ffmpeg::{codec, encoder, Dictionary, Packet, Rational};
|
||||
use ffmpeg::{codec, encoder, Dictionary};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::fd::AsRawFd;
|
||||
@@ -34,18 +34,11 @@ use std::os::raw::c_int;
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
// libswscale scaler-flag + colour-space constants (not exported as Rust consts by the bindings;
|
||||
// these are the stable `<libswscale/swscale.h>` #defines). No-rescale → POINT is cheapest.
|
||||
const SWS_POINT: c_int = 0x10;
|
||||
const SWS_CS_ITU709: c_int = 1;
|
||||
|
||||
/// `ffmpeg::format::Pixel` → raw `AVPixelFormat` (the documented ffmpeg-next conversion).
|
||||
fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
|
||||
ffi::AVPixelFormat::from(p)
|
||||
}
|
||||
|
||||
/// `fourcc(a,b,c,d)` — DRM FourCC packing (`a | b<<8 | c<<16 | d<<24`).
|
||||
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
||||
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||||
@@ -191,20 +184,9 @@ unsafe fn open_vaapi_encoder_mode(
|
||||
video.set_width(width);
|
||||
video.set_height(height);
|
||||
video.set_format(Pixel::NV12); // sw view; pix_fmt overridden to VAAPI below
|
||||
video.set_time_base(Rational(1, fps as i32));
|
||||
video.set_frame_rate(Some(Rational(fps as i32, 1)));
|
||||
video.set_bit_rate(bitrate_bps as usize);
|
||||
video.set_max_bit_rate(bitrate_bps as usize); // == target → vaapi_encode picks CBR when supported
|
||||
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f32>().ok())
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.unwrap_or(1.0);
|
||||
let vbv_bits =
|
||||
((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64).clamp(1.0, i32::MAX as f64);
|
||||
video.set_max_b_frames(0);
|
||||
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).rc_buffer_size = vbv_bits as i32;
|
||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
|
||||
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
|
||||
@@ -285,32 +267,6 @@ pub fn probe_can_encode_444(_codec: Codec) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Drain the encoder for one packet (shared poll logic).
|
||||
fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<Option<EncodedFrame>> {
|
||||
let mut pkt = Packet::empty();
|
||||
match enc.receive_packet(&mut pkt) {
|
||||
Ok(()) => {
|
||||
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
|
||||
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
|
||||
Ok(Some(EncodedFrame {
|
||||
data,
|
||||
pts_ns: pts * 1_000_000_000 / fps as u64,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
chunk_aligned: false,
|
||||
}))
|
||||
}
|
||||
Err(ffmpeg::Error::Other { errno })
|
||||
if errno == ffmpeg::util::error::EAGAIN
|
||||
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(ffmpeg::Error::Eof) => Ok(None),
|
||||
Err(e) => Err(e).context("receive_packet"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// CPU upload path (Phase 1): swscale RGB→NV12 → upload into a pooled VA surface → encode.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -1157,9 +1113,14 @@ impl Encoder for VaapiEncoder {
|
||||
.min(std::time::Duration::from_millis(12));
|
||||
let deadline = std::time::Instant::now() + budget;
|
||||
loop {
|
||||
if let Some(au) = poll_encoder(enc, self.fps)? {
|
||||
self.in_flight = self.in_flight.saturating_sub(1);
|
||||
return Ok(Some(au));
|
||||
match poll_encoder(enc, self.fps)? {
|
||||
PollOutcome::Packet(au) => {
|
||||
self.in_flight = self.in_flight.saturating_sub(1);
|
||||
return Ok(Some(au));
|
||||
}
|
||||
// No AU yet (EAGAIN) or drained (EOF) both fall through to the budget check,
|
||||
// exactly as the previous `Option::None` did.
|
||||
PollOutcome::Again | PollOutcome::Eof => {}
|
||||
}
|
||||
// Nothing ready: only wait when a frame is actually in flight (a drained/EOF'd
|
||||
// encoder must not spin the budget), and give the ASIC ~250 µs between checks.
|
||||
|
||||
@@ -1304,7 +1304,7 @@ impl VulkanVideoEncoder {
|
||||
// The retarget control command is recorded (execution follows submission order): the
|
||||
// session's RC state IS the new rate from this frame on — later begins declare it.
|
||||
self.bitrate = nb;
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
mbps = nb / 1_000_000,
|
||||
"vulkan-encode: rate control retargeted in place (no IDR)"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Shared direct-SDK NVENC core — the platform-agnostic pieces of the two `nvEncodeAPI` backends,
|
||||
//! Windows D3D11 (`encode/windows/nvenc.rs`) and Linux CUDA (`encode/linux/nvenc_cuda.rs`), so the
|
||||
//! byte-identical glue lives once (plan §2.2, the direct-NVENC Tier-2). The per-platform parts —
|
||||
//! the entry-table load (`nvEncodeAPI64.dll` via `LoadLibrary` vs `libnvidia-encode.so` via
|
||||
//! `libloading`), the device binding (D3D11 vs CUDA), input-surface registration, and the
|
||||
//! Windows-only async retrieve — stay in their backends. Sibling of [`super::nvenc_status`].
|
||||
|
||||
use super::Codec;
|
||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
||||
|
||||
/// Local `NVENCSTATUS` → `Result` (replaces the sdk's `result_without_string`, which lives in the
|
||||
/// crate's `safe` module — code these backends must not pull in). The raw status's Debug repr
|
||||
/// (`NV_ENC_ERR_INVALID_PARAM`, …) is the error payload; callers fold it through
|
||||
/// [`super::nvenc_status`] for an operator-actionable cause.
|
||||
pub(super) trait NvStatusExt {
|
||||
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS>;
|
||||
}
|
||||
impl NvStatusExt for nv::NVENCSTATUS {
|
||||
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS> {
|
||||
match self {
|
||||
nv::NVENCSTATUS::NV_ENC_SUCCESS => Ok(()),
|
||||
err => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The NVENC codec GUID for a session [`Codec`]. PyroWave never opens the direct-NVENC backend
|
||||
/// (guarded by the `open_video` dispatch), so it is unreachable here.
|
||||
pub(super) fn codec_guid(codec: Codec) -> nv::GUID {
|
||||
match codec {
|
||||
Codec::H264 => nv::NV_ENC_CODEC_H264_GUID,
|
||||
Codec::H265 => nv::NV_ENC_CODEC_HEVC_GUID,
|
||||
Codec::Av1 => nv::NV_ENC_CODEC_AV1_GUID,
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
|
||||
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
|
||||
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
|
||||
/// paths check against (`frame_idx - RFI_DPB` = the oldest frame still in the DPB).
|
||||
pub(super) const RFI_DPB: u32 = 5;
|
||||
|
||||
/// The per-session knobs both direct-NVENC backends feed [`apply_low_latency_config`]. `Copy` so the
|
||||
/// backend fills it from `self` at the call. The two input-format fields bridge the only real
|
||||
/// divergence between the CUDA and D3D11 paths (which surface formats can carry full chroma / 10-bit
|
||||
/// input); everything else in the config is identical across platforms.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct LowLatencyConfig {
|
||||
pub codec: Codec,
|
||||
/// Target bitrate (bps); CBR average == max.
|
||||
pub bitrate: u64,
|
||||
pub fps: u32,
|
||||
/// This GPU advertises custom VBV — else leave the preset default (per the caps probe).
|
||||
pub custom_vbv: bool,
|
||||
/// A 4:4:4 session was negotiated (HEVC Range Extensions).
|
||||
pub chroma_444: bool,
|
||||
/// The input surface can carry full chroma — Linux feeds a YUV444 surface, Windows a packed-RGB
|
||||
/// surface NVENC CSCs internally. 4:4:4 engages only when this AND [`chroma_444`](Self::chroma_444).
|
||||
pub full_chroma_input: bool,
|
||||
/// Output bit depth (8 or 10).
|
||||
pub bit_depth: u8,
|
||||
/// AV1 `inputPixelBitDepthMinus8` — the encoder's view of the INPUT depth (Linux is 8-bit in
|
||||
/// today, so 0; Windows derives it from the surface format). `u32` to match the SDK setter.
|
||||
pub av1_input_depth_minus8: u32,
|
||||
pub hdr: bool,
|
||||
/// This GPU supports reference-frame invalidation (a deeper DPB for graceful loss recovery).
|
||||
pub rfi_supported: bool,
|
||||
}
|
||||
|
||||
/// Author the shared `NV_ENC_INITIALIZE_PARAMS` (P1/ULL preset, PTD, the session dimensions/rate)
|
||||
/// pointing at `cfg`. `enable_async` drives the Windows two-thread async retrieve — Linux is
|
||||
/// sync-only and passes `false`, leaving `enableEncodeAsync` at 0 as before. The returned struct
|
||||
/// borrows `cfg` as a raw pointer; the caller must keep `cfg` alive across the NVENC call it feeds
|
||||
/// this into. Used at open and at in-place reconfigure, which must present the SAME init params.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn build_init_params(
|
||||
codec_guid: nv::GUID,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
cfg: &mut nv::NV_ENC_CONFIG,
|
||||
split_mode: u32,
|
||||
enable_async: bool,
|
||||
) -> nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
||||
encodeGUID: codec_guid,
|
||||
presetGUID: nv::NV_ENC_PRESET_P1_GUID,
|
||||
tuningInfo: nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
|
||||
encodeWidth: width,
|
||||
encodeHeight: height,
|
||||
darWidth: width,
|
||||
darHeight: height,
|
||||
frameRateNum: fps,
|
||||
frameRateDen: 1,
|
||||
enablePTD: 1,
|
||||
enableEncodeAsync: enable_async as u32,
|
||||
encodeConfig: cfg,
|
||||
..Default::default()
|
||||
};
|
||||
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
||||
init.set_splitEncodeMode(split_mode);
|
||||
init
|
||||
}
|
||||
|
||||
/// Author the shared low-latency NVENC config onto a **preset-seeded** `cfg`: CBR + infinite GOP +
|
||||
/// P-only + ~1-frame VBV, per-codec tier/level, chroma + bit depth, unconditional colour signaling,
|
||||
/// and the RFI DPB. The caller seeds `cfg` from the P1/ULL preset first (that call needs the
|
||||
/// per-platform entry table) and passes the input-format specifics via [`LowLatencyConfig`]; the
|
||||
/// per-platform surface registration, device binding, and async retrieve stay in the backends.
|
||||
///
|
||||
/// # Safety
|
||||
/// Writes NVENC codec-config union fields on `cfg`, which must be a valid, preset-seeded
|
||||
/// `NV_ENC_CONFIG` whose active union arm matches [`LowLatencyConfig::codec`].
|
||||
pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: LowLatencyConfig) {
|
||||
// CBR, infinite GOP, P-only, ~1-frame VBV — mirrors the AMF/VAAPI/QSV libav RC config.
|
||||
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
|
||||
cfg.frameIntervalP = 1;
|
||||
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
|
||||
let bps = c.bitrate.min(u32::MAX as u64) as u32;
|
||||
cfg.rcParams.averageBitRate = bps;
|
||||
cfg.rcParams.maxBitRate = bps;
|
||||
// Shrink the VBV with the bitrate (NVENC validates it against the same level ceiling), but only
|
||||
// when the GPU advertises custom-VBV support — else keep the preset default.
|
||||
if c.custom_vbv {
|
||||
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
|
||||
let vbv = ((c.bitrate as f64 / c.fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
||||
.clamp(1.0, u32::MAX as f64) as u32;
|
||||
cfg.rcParams.vbvBufferSize = vbv;
|
||||
cfg.rcParams.vbvInitialDelay = vbv;
|
||||
}
|
||||
|
||||
// Tier + autoselect level, PER CODEC (the union writes must match the negotiated codec). HEVC
|
||||
// keeps HIGH tier for its higher per-level bitrate ceiling (Main at 5K ≈ 240 Mbps, HIGH ≈ 800
|
||||
// Mbps); AV1 supports the Main tier ONLY — tier=1 fails the session open with INVALID_PARAM, and
|
||||
// its level enum's 0 is LEVEL 2.0 (NOT autoselect), so AV1 takes NO writes (the preset defaults
|
||||
// are the only accepted config). H.264 has no tier. Level 0 = autoselect for HEVC.
|
||||
match c.codec {
|
||||
Codec::H265 => {
|
||||
cfg.encodeCodecConfig.hevcConfig.tier = 1;
|
||||
cfg.encodeCodecConfig.hevcConfig.level = 0;
|
||||
}
|
||||
Codec::Av1 => {}
|
||||
Codec::H264 => {}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
|
||||
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3 under the FREXT
|
||||
// profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma-
|
||||
// capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the
|
||||
// HEVC Main10 GUID onto an AV1 session is an INVALID_PARAM, so bit depth is set PER CODEC.
|
||||
if c.chroma_444 && c.full_chroma_input {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
||||
if c.bit_depth == 10 {
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // Main 4:4:4 10
|
||||
}
|
||||
} else if c.bit_depth == 10 {
|
||||
match c.codec {
|
||||
Codec::H265 => {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
|
||||
}
|
||||
Codec::Av1 => {
|
||||
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
|
||||
cfg.encodeCodecConfig
|
||||
.av1Config
|
||||
.set_inputPixelBitDepthMinus8(c.av1_input_depth_minus8);
|
||||
}
|
||||
Codec::H264 => {} // no 10-bit H.264 encode on NVENC — negotiation never asks
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
// Colour signaling, written UNCONDITIONALLY: the input is already CSC'd to a specific matrix
|
||||
// (BT.709 limited SDR, or BT.2020 PQ for HDR), so the stream must say so — a decoder whose
|
||||
// "unspecified" default is 601 (Moonlight/third-party/Android-vendor at sub-HD) otherwise
|
||||
// mis-renders. HEVC/H.264 carry it in the VUI; AV1 has no VUI, so the same CICP code points go in
|
||||
// the sequence-header colour config.
|
||||
{
|
||||
let (prim, trc, mat) = if c.hdr {
|
||||
(
|
||||
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020,
|
||||
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084,
|
||||
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT709,
|
||||
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709,
|
||||
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT709,
|
||||
)
|
||||
};
|
||||
match c.codec {
|
||||
Codec::H265 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = prim;
|
||||
vui.transferCharacteristics = trc;
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::H264 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = prim;
|
||||
vui.transferCharacteristics = trc;
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
let av1 = &mut cfg.encodeCodecConfig.av1Config;
|
||||
av1.colorPrimaries = prim;
|
||||
av1.transferCharacteristics = trc;
|
||||
av1.matrixCoefficients = mat;
|
||||
av1.colorRange = 0; // studio/limited swing
|
||||
}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
// Reference-frame invalidation: a deeper DPB so an invalidated reference can fall back to an
|
||||
// older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each P-frame
|
||||
// single-reference for low latency. Only when this GPU supports RFI.
|
||||
if c.rfi_supported {
|
||||
let one = nv::NV_ENC_NUM_REF_FRAMES::NV_ENC_NUM_REF_FRAMES_1;
|
||||
match c.codec {
|
||||
Codec::H264 => {
|
||||
cfg.encodeCodecConfig.h264Config.maxNumRefFrames = RFI_DPB;
|
||||
cfg.encodeCodecConfig.h264Config.numRefL0 = one;
|
||||
}
|
||||
Codec::H265 => {
|
||||
cfg.encodeCodecConfig.hevcConfig.maxNumRefFramesInDPB = RFI_DPB;
|
||||
cfg.encodeCodecConfig.hevcConfig.numRefL0 = one;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
cfg.encodeCodecConfig.av1Config.maxNumRefFramesInDPB = RFI_DPB;
|
||||
}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Actionable explanations for `NVENCSTATUS` failures — shared by the direct-SDK NVENC backends on
|
||||
//! Windows (`encode/windows/nvenc.rs`) and Linux (`encode/linux/nvenc_cuda.rs`).
|
||||
//!
|
||||
//! Every NVENC entry-point failure used to be annotated `(no NVIDIA GPU?)`, which actively misled
|
||||
//! triage: the direct-NVENC path only loads on a machine that HAS an NVIDIA GPU, and the failure a
|
||||
//! user actually hit — `NV_ENC_ERR_INVALID_VERSION` from a userspace/kernel driver version skew,
|
||||
//! fixed by a reboot — has nothing to do with a missing GPU. This maps each status to what it really
|
||||
//! means and what the operator should do, and folds that cause into the `anyhow::Error` at
|
||||
//! construction, so every downstream `{e:#}` log (the encode-recovery loop, session teardown) says
|
||||
//! the useful thing without extra plumbing.
|
||||
|
||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
||||
|
||||
/// A one-line, operator-actionable cause for an NVENC status. Does not repeat the raw code —
|
||||
/// callers print that alongside (see [`call_err`]). Public for the few sites that build a
|
||||
/// `String`/`format!` error instead of an `anyhow::Error`.
|
||||
pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
|
||||
match status {
|
||||
// The one this whole module exists for: a version word the driver rejects. Either the
|
||||
// driver is genuinely older than our headers, or (the sneaky case) the userspace
|
||||
// `libnvidia-encode` reports a new-enough version to the pre-flight probe but the running
|
||||
// kernel module is older and rejects the session — the classic "updated the driver, didn't
|
||||
// reboot" skew. Both heal the same way.
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_VERSION => format!(
|
||||
"the NVIDIA driver is older than this build's NVENC headers (needs NVENC API {}.{} or \
|
||||
newer), or the userspace and kernel-module driver versions are mismatched — common \
|
||||
right after a driver update without a reboot. Update the NVIDIA driver, or reboot if \
|
||||
you just updated it (a host restart is the usual fix).",
|
||||
nv::NVENCAPI_MAJOR_VERSION,
|
||||
nv::NVENCAPI_MINOR_VERSION,
|
||||
),
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_NO_ENCODE_DEVICE => {
|
||||
"this GPU exposes no usable NVENC engine — it has no hardware video encoder, or NVENC is \
|
||||
disabled on this card"
|
||||
.to_string()
|
||||
}
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_UNSUPPORTED_DEVICE => {
|
||||
"this GPU model is not supported by NVENC".to_string()
|
||||
}
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_ENCODERDEVICE
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_INVALID_DEVICE => {
|
||||
"the device/context handed to NVENC is invalid — a GPU reset or driver reload can cause \
|
||||
this"
|
||||
.to_string()
|
||||
}
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_DEVICE_NOT_EXIST => {
|
||||
"the NVENC device no longer exists — the driver reset, or the GPU fell off the bus"
|
||||
.to_string()
|
||||
}
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_OUT_OF_MEMORY => "the GPU is out of memory".to_string(),
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INCOMPATIBLE_CLIENT_KEY => {
|
||||
"NVENC rejected the client key — the GeForce concurrent-NVENC-session limit was reached, \
|
||||
or the driver is unlicensed for this many encoders"
|
||||
.to_string()
|
||||
}
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_UNIMPLEMENTED
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_UNSUPPORTED_PARAM => {
|
||||
"this driver/GPU does not implement the requested NVENC encode mode".to_string()
|
||||
}
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PARAM => {
|
||||
"NVENC rejected a parameter — an encode mode this GPU does not support".to_string()
|
||||
}
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_ENCODER_BUSY => {
|
||||
"the NVENC engine is busy — retry, or reduce the number of concurrent encode sessions"
|
||||
.to_string()
|
||||
}
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_GENERIC => {
|
||||
"the NVIDIA driver returned a generic NVENC failure — check dmesg and the driver install"
|
||||
.to_string()
|
||||
}
|
||||
other => format!("unexpected NVENC status ({other:?})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an actionable `anyhow::Error` for a failed NVENC entry-point call. `call` names the API
|
||||
/// (e.g. `"open_encode_session_ex"`); the message carries both the raw status and its real-world
|
||||
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)".
|
||||
pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error {
|
||||
anyhow::anyhow!("NVENC {call} failed: {status:?} — {}", explain(status))
|
||||
}
|
||||
@@ -596,7 +596,7 @@ fn try_factory() -> std::result::Result<&'static AmfLib, &'static str> {
|
||||
let lib = load_factory();
|
||||
if let Err(e) = &lib {
|
||||
// Once per process; only reachable when the backend resolved to AMF on this box.
|
||||
tracing::warn!("native AMF runtime unavailable: {e}");
|
||||
tracing::warn!(error = %e, "native AMF runtime unavailable");
|
||||
}
|
||||
lib
|
||||
})
|
||||
@@ -1101,7 +1101,8 @@ unsafe fn set_prop(
|
||||
} else {
|
||||
tracing::debug!(
|
||||
property = %name,
|
||||
result = %format!("{} ({r})", result_name(r)),
|
||||
result = result_name(r),
|
||||
amf_code = r,
|
||||
"optional AMF encoder property rejected (VCN generation/driver) — continuing"
|
||||
);
|
||||
Ok(false)
|
||||
@@ -1666,15 +1667,18 @@ impl AmfEncoder {
|
||||
tracing::info!(
|
||||
codec = ?self.codec,
|
||||
context = context_no,
|
||||
device = format!("{:#x}", device.as_raw() as usize),
|
||||
"native AMF encode active (context #{context_no}, {}x{}@{}, zero-copy D3D11 {} ring, runtime {}.{}.{})",
|
||||
self.width,
|
||||
self.height,
|
||||
self.fps,
|
||||
if self.ten_bit { "P010" } else { "NV12" },
|
||||
(lib.version >> 48) & 0xffff,
|
||||
(lib.version >> 32) & 0xffff,
|
||||
(lib.version >> 16) & 0xffff,
|
||||
device = %format_args!("{:#x}", device.as_raw() as usize),
|
||||
width = self.width,
|
||||
height = self.height,
|
||||
fps = self.fps,
|
||||
ring = if self.ten_bit { "P010" } else { "NV12" },
|
||||
runtime = %format_args!(
|
||||
"{}.{}.{}",
|
||||
(lib.version >> 48) & 0xffff,
|
||||
(lib.version >> 32) & 0xffff,
|
||||
(lib.version >> 16) & 0xffff
|
||||
),
|
||||
"native AMF encode active (zero-copy D3D11)"
|
||||
);
|
||||
self.inner = Some(Inner {
|
||||
comp,
|
||||
@@ -1766,6 +1770,30 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
/// [`probe_can_encode`] against an explicit device (separated so the live tests can pin the AMD
|
||||
/// adapter on a hybrid box).
|
||||
fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
|
||||
probe_open_on(device, codec, false)
|
||||
}
|
||||
|
||||
/// Native factory probe for **10-bit** encode: can this GPU's AMF runtime `Init` a `codec`
|
||||
/// encoder at 10-bit (Main10 profile / `*ColorBitDepth` 10, P010 input)? The driver rejects the
|
||||
/// profile/depth props on VCN generations that can't encode them, so a successful tiny `Init` is
|
||||
/// the honest per-codec answer — read *before* the Welcome by
|
||||
/// [`crate::encode::can_encode_10bit`] so the negotiated bit depth matches what the session's
|
||||
/// encoder will really open. H.264 is always `false` (High10 is not a VCN mode — the session
|
||||
/// open bails on it too).
|
||||
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
if !codec.supports_10bit() {
|
||||
return false;
|
||||
}
|
||||
let Some(device) = selected_adapter_device() else {
|
||||
return false;
|
||||
};
|
||||
probe_open_on(&device, codec, true)
|
||||
}
|
||||
|
||||
/// Shared probe body: a context on `device`, the codec's component, the usage preset, optionally
|
||||
/// the 10-bit profile/depth props, then a tiny `Init` (P010 surface when `ten_bit`, NV12
|
||||
/// otherwise). Everything is torn down before returning; `false` on any failure.
|
||||
fn probe_open_on(device: &ID3D11Device, codec: Codec, ten_bit: bool) -> bool {
|
||||
if try_factory().is_err() {
|
||||
return false;
|
||||
}
|
||||
@@ -1774,7 +1802,8 @@ fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
|
||||
// object is moved into a guard (`Ctx`/`Component`) immediately, so each early return releases
|
||||
// exactly once; `InitDX11` borrows the live `device` for the synchronous call (AMF holds its
|
||||
// own device reference until the guard's Terminate). Usage must be set before `Init` (the
|
||||
// header marks its default "N/A") — the probe mirrors the session's open order.
|
||||
// header marks its default "N/A") — the probe mirrors the session's open order, including
|
||||
// the 10-bit profile/depth props (`configure`'s required set_props) when probing 10-bit.
|
||||
unsafe {
|
||||
let Ok(lib) = try_factory() else { return false };
|
||||
let mut ctx: *mut sys::AmfContext = ptr::null_mut();
|
||||
@@ -1807,7 +1836,32 @@ fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
((*(*comp.0).vtbl).init)(comp.0, sys::AMF_SURFACE_NV12, 640, 480) == sys::AMF_OK
|
||||
if ten_bit {
|
||||
// The same required props `configure` sets for a 10-bit session — a driver that can't
|
||||
// honor them rejects here, which is exactly the probe's answer.
|
||||
let depth_props: &[(PCWSTR, i64)] = match codec {
|
||||
Codec::H265 => &[
|
||||
(w!("HevcProfile"), HEVC_PROFILE_MAIN_10),
|
||||
(w!("HevcColorBitDepth"), COLOR_BIT_DEPTH_10),
|
||||
],
|
||||
// 10-bit is part of AV1 Main profile — only the surface depth needs forcing.
|
||||
Codec::Av1 => &[(w!("Av1ColorBitDepth"), COLOR_BIT_DEPTH_10)],
|
||||
Codec::H264 | Codec::PyroWave => return false,
|
||||
};
|
||||
for (name, value) in depth_props {
|
||||
if ((*(*comp.0).vtbl).set_property)(comp.0, name.0, AmfVariant::from_i64(*value))
|
||||
!= sys::AMF_OK
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let surface = if ten_bit {
|
||||
sys::AMF_SURFACE_P010
|
||||
} else {
|
||||
sys::AMF_SURFACE_NV12
|
||||
};
|
||||
((*(*comp.0).vtbl).init)(comp.0, surface, 640, 480) == sys::AMF_OK
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2147,7 +2201,8 @@ impl Encoder for AmfEncoder {
|
||||
);
|
||||
if r != sys::AMF_OK {
|
||||
tracing::warn!(
|
||||
result = %format!("{} ({r})", result_name(r)),
|
||||
result = result_name(r),
|
||||
amf_code = r,
|
||||
"AMF forced-keyframe picture type rejected"
|
||||
);
|
||||
}
|
||||
@@ -2192,7 +2247,8 @@ impl Encoder for AmfEncoder {
|
||||
if r != sys::AMF_OK {
|
||||
tracing::warn!(
|
||||
slot,
|
||||
result = %format!("{} ({r})", result_name(r)),
|
||||
result = result_name(r),
|
||||
amf_code = r,
|
||||
"AMF LTR mark rejected"
|
||||
);
|
||||
}
|
||||
@@ -2212,7 +2268,8 @@ impl Encoder for AmfEncoder {
|
||||
} else {
|
||||
tracing::warn!(
|
||||
slot,
|
||||
result = %format!("{} ({r})", result_name(r)),
|
||||
result = result_name(r),
|
||||
amf_code = r,
|
||||
"AMF LTR force-reference rejected — client stays frozen until its IDR fallback"
|
||||
);
|
||||
}
|
||||
@@ -2558,7 +2615,11 @@ impl Encoder for AmfEncoder {
|
||||
// end-of-stream (remaining AUs then surface through `poll` until AMF_EOF).
|
||||
let r = unsafe { ((*(*inner.comp.0).vtbl).drain)(inner.comp.0) };
|
||||
if r != sys::AMF_OK {
|
||||
tracing::debug!(result = %format!("{} ({r})", result_name(r)), "AMF Drain");
|
||||
tracing::debug!(
|
||||
result = result_name(r),
|
||||
amf_code = r,
|
||||
"AMF Drain returned non-OK at flush"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2690,7 +2751,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Drive `enc` at the real frame cadence and return each frame's **submit→AU** wall-clock
|
||||
/// (µs) — the `encode_us` the punktfunk1 loop records. Mirrors the depth-1 loop exactly:
|
||||
/// (µs) — the `encode_us` the native loop records. Mirrors the depth-1 loop exactly:
|
||||
/// pace to `1/fps`, timestamp the submit, then drain whatever AUs are ready and FIFO-pair
|
||||
/// them to their submit stamps. The libavcodec AMF wrapper's ~2-frame output hold therefore
|
||||
/// shows up here as ~2 frame periods (the AU for frame N emerges only once N+2 is submitted),
|
||||
|
||||
@@ -44,7 +44,7 @@ use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
|
||||
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ffmpeg::format::Pixel;
|
||||
use ffmpeg::{codec, encoder, Dictionary, Packet, Rational};
|
||||
use ffmpeg::{codec, encoder, Dictionary};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::os::raw::{c_int, c_uint, c_void};
|
||||
use std::ptr;
|
||||
@@ -60,14 +60,12 @@ use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_BT2020, SWS_CS_ITU709,
|
||||
SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
// libswscale scaler-flag + colour-space constants (not exported as Rust consts by the bindings —
|
||||
// the stable `<libswscale/swscale.h>` #defines, same as the VAAPI path uses).
|
||||
const SWS_POINT: c_int = 0x10;
|
||||
const SWS_CS_ITU709: c_int = 1;
|
||||
const SWS_CS_BT2020: c_int = 9;
|
||||
|
||||
/// `AVD3D11VADeviceContext` (libavutil/hwcontext_d3d11va.h) — mirrored (the ffmpeg-sys bindings
|
||||
/// don't allowlist that header). We set `device` to the capturer's `ID3D11Device` so AMF/QSV share
|
||||
/// it; `av_hwdevice_ctx_init` fills `device_context`/`video_device`/`video_context`/the default
|
||||
@@ -149,11 +147,6 @@ fn is_10bit_format(format: PixelFormat, bit_depth: u8) -> bool {
|
||||
bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
||||
}
|
||||
|
||||
/// `ffmpeg::format::Pixel` → raw `AVPixelFormat`.
|
||||
fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
|
||||
ffi::AVPixelFormat::from(p)
|
||||
}
|
||||
|
||||
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
|
||||
/// infinite GOP, the BT.709-limited (SDR) or BT.2020-PQ (HDR) VUI, the given `pix_fmt`, and the
|
||||
/// optional hw device/frames contexts (null for the system path). Returns the opened encoder.
|
||||
@@ -187,20 +180,9 @@ unsafe fn open_win_encoder(
|
||||
// Software view of the input layout (NV12 / P010). For the hw paths `pix_fmt` is overridden to
|
||||
// D3D11/QSV below; libavcodec still uses this as `sw_pix_fmt`.
|
||||
video.set_format(Pixel::from(sw_pix_fmt));
|
||||
video.set_time_base(Rational(1, fps as i32));
|
||||
video.set_frame_rate(Some(Rational(fps as i32, 1)));
|
||||
video.set_bit_rate(bitrate_bps as usize);
|
||||
video.set_max_bit_rate(bitrate_bps as usize); // target == max → CBR
|
||||
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f32>().ok())
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.unwrap_or(1.0);
|
||||
let vbv_bits =
|
||||
((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64).clamp(1.0, i32::MAX as f64);
|
||||
video.set_max_b_frames(0);
|
||||
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).rc_buffer_size = vbv_bits as i32;
|
||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||
if ten_bit {
|
||||
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
|
||||
@@ -277,7 +259,7 @@ unsafe fn open_win_encoder(
|
||||
/// `false` keeps the negotiation honest: an AMF/QSV host resolves every session to 4:2:0 before the
|
||||
/// Welcome. (Follow-up: implement + validate on an RDNA3+/Arc Windows box.)
|
||||
pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
|
||||
tracing::info!("AMF/QSV HEVC 4:4:4 encode is not implemented yet — declining (encoding 4:2:0)");
|
||||
tracing::debug!("AMF/QSV HEVC 4:4:4 encode not implemented — declining (4:2:0)");
|
||||
false
|
||||
}
|
||||
|
||||
@@ -320,40 +302,6 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// One `receive_packet` attempt, with the not-ready states kept distinct so the blocking poll
|
||||
/// below can tell "still encoding" (retry) from "stream over" (stop).
|
||||
enum PollOutcome {
|
||||
Packet(EncodedFrame),
|
||||
Again,
|
||||
Eof,
|
||||
}
|
||||
|
||||
/// Drain the encoder for one packet (shared poll logic, identical to the VAAPI/NVENC paths).
|
||||
fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<PollOutcome> {
|
||||
let mut pkt = Packet::empty();
|
||||
match enc.receive_packet(&mut pkt) {
|
||||
Ok(()) => {
|
||||
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
|
||||
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
|
||||
Ok(PollOutcome::Packet(EncodedFrame {
|
||||
data,
|
||||
pts_ns: pts * 1_000_000_000 / fps as u64,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
chunk_aligned: false,
|
||||
}))
|
||||
}
|
||||
Err(ffmpeg::Error::Other { errno })
|
||||
if errno == ffmpeg::util::error::EAGAIN
|
||||
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
|
||||
{
|
||||
Ok(PollOutcome::Again)
|
||||
}
|
||||
Err(ffmpeg::Error::Eof) => Ok(PollOutcome::Eof),
|
||||
Err(e) => Err(e).context("receive_packet"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The immediate context of an `ID3D11Device` (for `CopyResource`/`CopySubresourceRegion`).
|
||||
unsafe fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
|
||||
// windows-rs 0.62: the inherent method takes no args and returns the context (the OutRef form is
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
@@ -116,21 +120,6 @@ struct EncodeApi {
|
||||
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
|
||||
}
|
||||
|
||||
/// Local `NVENCSTATUS` → `Result` (replaces the sdk's `result_without_string`, which lives in the
|
||||
/// crate's `safe` module — code this file must not pull in, see [`EncodeApi`]). The raw status's
|
||||
/// Debug repr (`NV_ENC_ERR_INVALID_PARAM`, …) is the error payload.
|
||||
trait NvStatusExt {
|
||||
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS>;
|
||||
}
|
||||
impl NvStatusExt for nv::NVENCSTATUS {
|
||||
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS> {
|
||||
match self {
|
||||
nv::NVENCSTATUS::NV_ENC_SUCCESS => Ok(()),
|
||||
err => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable on this machine (no
|
||||
/// NVIDIA driver/DLL, or a driver older than our headers) — the entry points
|
||||
/// ([`NvencD3d11Encoder::open`], [`probe_can_encode_444`]) gate on it and the AMF/QSV/software
|
||||
@@ -144,7 +133,7 @@ fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
|
||||
if let Err(e) = &table {
|
||||
// Once per process. Only reachable when something resolved to NVENC on this box
|
||||
// (backend misdetect or a forced PUNKTFUNK_ENCODER=nvenc) — say why it will fail.
|
||||
tracing::warn!("NVENC API unavailable: {e}");
|
||||
tracing::warn!(error = %e, "NVENC API unavailable");
|
||||
}
|
||||
table
|
||||
})
|
||||
@@ -234,20 +223,6 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||
// GPU-saturating game; this must be ≥ the helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
|
||||
const POOL: usize = 8;
|
||||
|
||||
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5 for H.264/HEVC). A deeper DPB
|
||||
/// lets an invalidated reference fall back to an older still-valid frame instead of a full IDR;
|
||||
/// `numRefL0 = 1` keeps each P-frame single-reference for low latency.
|
||||
const RFI_DPB: u32 = 5;
|
||||
|
||||
fn codec_guid(codec: Codec) -> nv::GUID {
|
||||
match codec {
|
||||
Codec::H264 => nv::NV_ENC_CODEC_H264_GUID,
|
||||
Codec::H265 => nv::NV_ENC_CODEC_HEVC_GUID,
|
||||
Codec::Av1 => nv::NV_ENC_CODEC_AV1_GUID,
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Live NVENC hardware-session units held by THIS host process (a plain session = 1; a forced
|
||||
/// split-encode session occupies one session per engine = 2–3) — the Stage-W3 encoder budget
|
||||
/// (`design/windows-parallel-virtual-displays.md` §4.5). Kept in ONE place so admitting a parallel
|
||||
@@ -346,7 +321,7 @@ fn retrieve_loop(
|
||||
work_rx: mpsc::Receiver<RetrieveJob>,
|
||||
done_tx: mpsc::Sender<RetrieveDone>,
|
||||
) {
|
||||
crate::punktfunk1::boost_thread_priority(false);
|
||||
crate::native::boost_thread_priority(false);
|
||||
while let Ok(job) = work_rx.recv() {
|
||||
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
|
||||
// registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
|
||||
@@ -381,7 +356,10 @@ fn retrieve_loop(
|
||||
let _ = (api().unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void);
|
||||
Ok((data, keyframe))
|
||||
}
|
||||
Err(e) => Err(format!("lock_bitstream (async): {e:?}")),
|
||||
Err(e) => Err(format!(
|
||||
"lock_bitstream (async): {e:?} — {}",
|
||||
nvenc_status::explain(e)
|
||||
)),
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -585,7 +563,16 @@ impl NvencD3d11Encoder {
|
||||
for &bs in &self.bitstreams {
|
||||
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
|
||||
}
|
||||
let _ = (api().destroy_encoder)(self.encoder);
|
||||
// A destroy failure means the driver may still hold this session's slot (the concurrent-
|
||||
// session cap is per process and only a restart clears a leak) — make it visible instead
|
||||
// of silently discarding the status.
|
||||
if let Err(e) = (api().destroy_encoder)(self.encoder).nv_ok() {
|
||||
tracing::warn!(
|
||||
status = ?e,
|
||||
"NVENC destroy_encoder failed at teardown — the driver may have leaked this \
|
||||
session's slot toward the concurrent-session cap"
|
||||
);
|
||||
}
|
||||
// Return this session's units to the budget (see LIVE_SESSION_UNITS).
|
||||
LIVE_SESSION_UNITS.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||
self.session_units = 0;
|
||||
@@ -633,11 +620,19 @@ impl NvencD3d11Encoder {
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
||||
.nv_ok()
|
||||
.map_err(|e| {
|
||||
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
|
||||
})?;
|
||||
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
|
||||
// The NVENC docs require NvEncDestroyEncoder even after a FAILED open (the driver may
|
||||
// have allocated the session slot before erroring) — without it, every failed open in
|
||||
// a retry loop leaks a slot toward the concurrent-session cap, turning a transient
|
||||
// failure into permanent exhaustion that only a host restart clears.
|
||||
if !enc.is_null() {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
}
|
||||
return Err(nvenc_status::call_err(
|
||||
"open_encode_session_ex (caps probe)",
|
||||
e,
|
||||
));
|
||||
}
|
||||
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
|
||||
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
|
||||
let ten_bit = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE);
|
||||
@@ -712,222 +707,41 @@ impl NvencD3d11Encoder {
|
||||
&mut preset,
|
||||
)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("get_encode_preset_config_ex: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("get_encode_preset_config_ex", e))?;
|
||||
let mut cfg = preset.presetCfg;
|
||||
|
||||
// Mirror the Linux RC config: CBR, infinite GOP, P-only, ~1-frame VBV.
|
||||
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
|
||||
cfg.frameIntervalP = 1;
|
||||
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
|
||||
let bps = bitrate.min(u32::MAX as u64) as u32;
|
||||
cfg.rcParams.averageBitRate = bps;
|
||||
cfg.rcParams.maxBitRate = bps;
|
||||
// Shrink the VBV with the bitrate — NVENC validates it against the same level ceiling. Only
|
||||
// when the GPU advertises custom-VBV support (else leave the preset default, per the caps probe).
|
||||
if self.custom_vbv {
|
||||
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
|
||||
let vbv = ((bitrate as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
||||
.clamp(1.0, u32::MAX as f64) as u32;
|
||||
cfg.rcParams.vbvBufferSize = vbv;
|
||||
cfg.rcParams.vbvInitialDelay = vbv;
|
||||
}
|
||||
|
||||
// Tier + autoselect level, PER CODEC — these union writes must match the negotiated codec.
|
||||
// The old unconditional `hevcConfig.tier = 1` relied on "HEVC/AV1 share the union offset",
|
||||
// which is true for the offsets but WRONG for the values: NVENC's AV1 encoder supports the
|
||||
// Main tier only, and tier=1 fails the whole session open with NV_ENC_ERR_INVALID_PARAM
|
||||
// (the "AV1 negotiates fine but the encoder rejects at any bitrate" field bug). It also
|
||||
// scribbled HEVC offsets into h264Config, where they alias unrelated fields.
|
||||
// HEVC keeps HIGH tier: its PER-LEVEL bitrate ceiling is otherwise the MAIN-tier cap — at
|
||||
// 5K that's Level 6.2 Main ≈ 240 Mbps; HIGH lifts it to ≈800 Mbps. AV1's Main-tier level
|
||||
// ceilings are high enough that autoselect alone suffices. Level 0 = autoselect for both.
|
||||
match self.codec {
|
||||
Codec::H265 => {
|
||||
cfg.encodeCodecConfig.hevcConfig.tier = 1;
|
||||
cfg.encodeCodecConfig.hevcConfig.level = 0;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
// Deliberately NO writes: the preset defaults are already the only accepted
|
||||
// configuration — Main tier (tier=1 fails init: NVENC AV1 has no HIGH tier) and
|
||||
// autoselect level. Do NOT copy HEVC's `level = 0` here: in the AV1 level enum
|
||||
// 0 is LEVEL 2.0 (autoselect is a distinct constant), so "0 = autoselect" is an
|
||||
// HEVC-ism that pins AV1 to its smallest level and rejects any real stream.
|
||||
// idrPeriod likewise stays at the preset default: with PTD enabled the driver
|
||||
// follows `gopLength` (INFINITE above), and writing INFINITE into it explicitly
|
||||
// is itself rejected (all verified live on a 4090 / driver 561).
|
||||
}
|
||||
// H.264 has no tier; the preset default level is already autoselect.
|
||||
Codec::H264 => {}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
|
||||
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions) takes precedence and composes
|
||||
// with 10-bit (Main 4:4:4 10): NVENC ingests the RGB input (ARGB / ABGR10) and CSCs it to
|
||||
// YUV444 internally when `chromaFormatIDC = 3` under the FREXT profile. Only valid on an RGB
|
||||
// input — a subsampled NV12/P010 source can't reconstruct full chroma (so the capturer is
|
||||
// forced to RGB for a 4:4:4 session, and we guard on the input format here too).
|
||||
//
|
||||
// ON-GLASS MEASURED (RTX 5070 Ti, driver 610.43, 2026-07-10 — `nvenc_444_on_glass_probe`
|
||||
// below + colour-bar analysis): ARGB + chromaFormatIDC=3 + FREXT yields a TRUE 4:4:4
|
||||
// stream (1-px chroma stripes survive, adjacent-column |dU| ≈ 138), and NVENC's internal
|
||||
// RGB→YUV conversion FOLLOWS THE CONFIGURED VUI MATRIX (bars match BT.709 within ±1 code
|
||||
// with our 709 VUI; the same driver produces exact BT.601 when libavcodec's nvenc wrapper
|
||||
// sets its BT470BG VUI on Linux). The always-written SDR VUI above therefore makes the
|
||||
// pixels and the signaling agree by construction — no AYUV shader needed.
|
||||
// Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
|
||||
// low-latency contract. On Windows the full-chroma input is a packed-RGB surface (NVENC
|
||||
// CSCs it internally under FREXT), and AV1's input-depth follows the surface format — 10-bit
|
||||
// for an ABGR10 / YUV420_10BIT input, else 8-bit.
|
||||
let rgb_input = matches!(
|
||||
self.buffer_fmt,
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
|
||||
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||
);
|
||||
if self.chroma_444 && rgb_input {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
||||
if self.bit_depth == 10 {
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // Main 4:4:4 10
|
||||
}
|
||||
} else if self.bit_depth == 10 {
|
||||
// 10-bit (HDR foundation): NVENC upconverts an 8-bit input; 8-bit leaves the preset
|
||||
// default profile untouched. PER CODEC — stamping the HEVC Main10 GUID + hevcConfig
|
||||
// bitfields onto an AV1 session was an unconditional INVALID_PARAM (the "AV1 10-bit
|
||||
// session never opens" field bug); AV1's Main profile already covers 10-bit, it only
|
||||
// needs the output depth set on its own config.
|
||||
match self.codec {
|
||||
Codec::H265 => {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
|
||||
// 10 - 8
|
||||
}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
Codec::Av1 => {
|
||||
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
|
||||
// The input rides at its real depth; NVENC upconverts (mirrors the HEVC path).
|
||||
let ten_bit_in = matches!(
|
||||
self.buffer_fmt,
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV420_10BIT
|
||||
);
|
||||
cfg.encodeCodecConfig
|
||||
.av1Config
|
||||
.set_inputPixelBitDepthMinus8(if ten_bit_in { 2 } else { 0 });
|
||||
}
|
||||
Codec::H264 => {} // no 10-bit H.264 encode on NVENC — negotiation never asks
|
||||
}
|
||||
}
|
||||
|
||||
// Colour signaling, written UNCONDITIONALLY (was HDR-only): the capturer hands NVENC
|
||||
// pre-converted NV12 (BT.709 limited, the IDD VideoConverter) or P010 (BT.2020 PQ
|
||||
// limited, the FP16→P010 shader), so the stream must SAY so — an SDR stream with no
|
||||
// colour description decodes correctly only on clients whose "unspecified" default
|
||||
// happens to be BT.709 limited (ours are, but Moonlight/third-party/Android-vendor
|
||||
// decoders default 601 at sub-HD resolutions). HEVC/H.264 carry it in the VUI; AV1 has
|
||||
// NO VUI, so the SAME CICP code points go in the sequence-header colour config
|
||||
// (`colorPrimaries`/`transferCharacteristics`/`matrixCoefficients`/`colorRange`).
|
||||
//
|
||||
// This is the per-stream colour *description* only. The static mastering-display (ST.2086)
|
||||
// and content-light (MaxCLL/MaxFALL) metadata — HEVC SEI / AV1 METADATA OBUs — is a
|
||||
// separate follow-up, as is wiring AV1/H.264 to a true 10-bit (Main10) encode (only HEVC
|
||||
// sets Main10 above today).
|
||||
{
|
||||
let (prim, trc, mat) = if self.hdr {
|
||||
(
|
||||
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020,
|
||||
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084,
|
||||
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT709,
|
||||
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709,
|
||||
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT709,
|
||||
)
|
||||
};
|
||||
match self.codec {
|
||||
Codec::H265 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = prim;
|
||||
vui.transferCharacteristics = trc;
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::H264 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = prim;
|
||||
vui.transferCharacteristics = trc;
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
let av1 = &mut cfg.encodeCodecConfig.av1Config;
|
||||
av1.colorPrimaries = prim;
|
||||
av1.transferCharacteristics = trc;
|
||||
av1.matrixCoefficients = mat;
|
||||
av1.colorRange = 0; // studio/limited swing
|
||||
}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
// Reference-frame invalidation: keep a deeper DPB so an invalidated reference can fall back
|
||||
// to an older still-valid frame instead of a full IDR, while `numRefL0 = 1` keeps each
|
||||
// P-frame single-reference for low latency. Only when this GPU supports RFI (else leave the
|
||||
// preset default — `invalidate_ref_frames` then returns false and the caller forces an IDR).
|
||||
if self.rfi_supported {
|
||||
let one = nv::NV_ENC_NUM_REF_FRAMES::NV_ENC_NUM_REF_FRAMES_1;
|
||||
match self.codec {
|
||||
Codec::H264 => {
|
||||
cfg.encodeCodecConfig.h264Config.maxNumRefFrames = RFI_DPB;
|
||||
cfg.encodeCodecConfig.h264Config.numRefL0 = one;
|
||||
}
|
||||
Codec::H265 => {
|
||||
cfg.encodeCodecConfig.hevcConfig.maxNumRefFramesInDPB = RFI_DPB;
|
||||
cfg.encodeCodecConfig.hevcConfig.numRefL0 = one;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
cfg.encodeCodecConfig.av1Config.maxNumRefFramesInDPB = RFI_DPB;
|
||||
}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
let ten_bit_in = matches!(
|
||||
self.buffer_fmt,
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV420_10BIT
|
||||
);
|
||||
apply_low_latency_config(
|
||||
&mut cfg,
|
||||
LowLatencyConfig {
|
||||
codec: self.codec,
|
||||
bitrate,
|
||||
fps: self.fps,
|
||||
custom_vbv: self.custom_vbv,
|
||||
chroma_444: self.chroma_444,
|
||||
full_chroma_input: rgb_input,
|
||||
bit_depth: self.bit_depth,
|
||||
av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 },
|
||||
hdr: self.hdr,
|
||||
rfi_supported: self.rfi_supported,
|
||||
},
|
||||
);
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Author the `NV_ENC_INITIALIZE_PARAMS` pointing at `cfg`. Shared by [`try_open_session`]
|
||||
/// and [`Encoder::reconfigure_bitrate`] — a reconfigure must present the SAME init params as
|
||||
/// the open. The returned struct borrows `cfg` raw; the caller keeps `cfg` alive across the
|
||||
/// NVENC call it feeds this into.
|
||||
fn build_init_params(
|
||||
&self,
|
||||
cfg: &mut nv::NV_ENC_CONFIG,
|
||||
split_mode: u32,
|
||||
enable_async: bool,
|
||||
) -> nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
||||
encodeGUID: self.codec_guid,
|
||||
presetGUID: nv::NV_ENC_PRESET_P1_GUID,
|
||||
tuningInfo: nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
|
||||
encodeWidth: self.width,
|
||||
encodeHeight: self.height,
|
||||
darWidth: self.width,
|
||||
darHeight: self.height,
|
||||
frameRateNum: self.fps,
|
||||
frameRateDen: 1,
|
||||
enablePTD: 1,
|
||||
// Two-thread async retrieve (§5.B): completion events signal the retrieve thread
|
||||
// instead of `lock_bitstream` blocking the submit thread.
|
||||
enableEncodeAsync: enable_async as u32,
|
||||
encodeConfig: cfg,
|
||||
..Default::default()
|
||||
};
|
||||
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
||||
init.set_splitEncodeMode(split_mode);
|
||||
init
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
|
||||
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
|
||||
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
|
||||
@@ -946,9 +760,14 @@ impl NvencD3d11Encoder {
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
|
||||
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
|
||||
// Destroy-on-failed-open, as in `query_caps`: a failed open may still hold a session
|
||||
// slot that must be released.
|
||||
if !enc.is_null() {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
}
|
||||
return Err(nvenc_status::call_err("open_encode_session_ex", e));
|
||||
}
|
||||
|
||||
let mut cfg = match self.build_config(enc, bitrate) {
|
||||
Ok(cfg) => cfg,
|
||||
@@ -957,13 +776,21 @@ impl NvencD3d11Encoder {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let mut init = self.build_init_params(&mut cfg, split_mode, enable_async);
|
||||
let mut init = build_init_params(
|
||||
self.codec_guid,
|
||||
self.width,
|
||||
self.height,
|
||||
self.fps,
|
||||
&mut cfg,
|
||||
split_mode,
|
||||
enable_async,
|
||||
);
|
||||
|
||||
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||
Ok(()) => Ok(enc),
|
||||
Err(e) => {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
Err(anyhow!("initialize_encoder: {e:?}"))
|
||||
Err(nvenc_status::call_err("initialize_encoder", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1023,11 +850,11 @@ impl NvencD3d11Encoder {
|
||||
}
|
||||
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||
};
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
split_mode,
|
||||
bit_depth = self.bit_depth,
|
||||
pixel_rate,
|
||||
"NVENC split-encode mode (0=auto 1=auto-forced 2=two 3=three 15=disable)"
|
||||
"NVENC split-encode mode selected"
|
||||
);
|
||||
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
|
||||
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
|
||||
@@ -1122,13 +949,8 @@ impl NvencD3d11Encoder {
|
||||
// mode (a split session occupies one hardware session per engine).
|
||||
self.session_units = split_mode_units(split_mode);
|
||||
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||
if self.bitrate_bps < requested_bps {
|
||||
tracing::info!(
|
||||
requested_mbps = requested_bps / 1_000_000,
|
||||
applied_mbps = self.bitrate_bps / 1_000_000,
|
||||
"NVENC bitrate capped to this GPU's max for the codec"
|
||||
);
|
||||
}
|
||||
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
|
||||
// info line for the same event here.)
|
||||
|
||||
// 5. one output bitstream per in-flight slot. There is NO encoder-owned input pool: the
|
||||
// capturer's textures are registered on demand in `submit` and encoded in place.
|
||||
@@ -1139,7 +961,7 @@ impl NvencD3d11Encoder {
|
||||
};
|
||||
(api().create_bitstream_buffer)(enc, &mut cb)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("create_bitstream_buffer", e))?;
|
||||
self.bitstreams.push(cb.bitstreamBuffer);
|
||||
}
|
||||
// Async retrieve: one auto-reset completion event per pool bitstream, registered with
|
||||
@@ -1156,7 +978,7 @@ impl NvencD3d11Encoder {
|
||||
};
|
||||
(api().register_async_event)(enc, &mut ep)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("register_async_event: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("register_async_event", e))?;
|
||||
self.events.push(ev.0 as usize);
|
||||
}
|
||||
let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
|
||||
@@ -1297,7 +1119,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
|
||||
// subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
|
||||
// native YUV despite a 4:4:4 negotiation, this session encodes 4:2:0 — clear the flag
|
||||
// NOW so `caps().chroma_444` (and punktfunk1's post-open cross-check) reports what
|
||||
// NOW so `caps().chroma_444` (and native's post-open cross-check) reports what
|
||||
// the stream really carries instead of silently claiming full chroma.
|
||||
if self.chroma_444
|
||||
&& !matches!(
|
||||
@@ -1372,7 +1194,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
};
|
||||
(api().register_resource)(self.encoder, &mut rr)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("register_resource: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("register_resource", e))?;
|
||||
self.regs
|
||||
.insert(key, (rr.registeredResource, frame.texture.clone()));
|
||||
}
|
||||
@@ -1385,7 +1207,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
};
|
||||
(api().map_input_resource)(self.encoder, &mut mp)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("map_input_resource: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("map_input_resource", e))?;
|
||||
|
||||
let pts = self.frame_idx as u64;
|
||||
self.frame_idx += 1;
|
||||
@@ -1469,7 +1291,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
(api().encode_picture)(self.encoder, &mut pic)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
|
||||
self.pending.push_back((
|
||||
self.bitstreams[slot],
|
||||
mp.mappedResource,
|
||||
@@ -1511,7 +1333,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
||||
EncoderCaps {
|
||||
supports_rfi: self.rfi_supported,
|
||||
supports_hdr_metadata: self.hdr,
|
||||
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
|
||||
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
|
||||
// (see `submit`); the grade still reaches punktfunk clients out-of-band via the 0xCE
|
||||
// datagram. Don't claim a capability the AV1 path doesn't have.
|
||||
supports_hdr_metadata: self.hdr && self.codec != Codec::Av1,
|
||||
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
|
||||
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
|
||||
chroma_444: self.chroma_444,
|
||||
@@ -1629,7 +1455,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
};
|
||||
(api().lock_bitstream)(self.encoder, &mut lock)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("lock_bitstream: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("lock_bitstream", e))?;
|
||||
let data = std::slice::from_raw_parts(
|
||||
lock.bitstreamBufferPtr as *const u8,
|
||||
lock.bitstreamSizeInBytes as usize,
|
||||
@@ -1641,7 +1467,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
);
|
||||
(api().unlock_bitstream)(self.encoder, bs)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
|
||||
.map_err(|e| nvenc_status::call_err("unlock_bitstream", e))?;
|
||||
if !map.is_null() {
|
||||
let _ = (api().unmap_input_resource)(self.encoder, map);
|
||||
}
|
||||
@@ -1694,7 +1520,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
};
|
||||
let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
|
||||
version: nv::NV_ENC_RECONFIGURE_PARAMS_VER,
|
||||
reInitEncodeParams: self.build_init_params(
|
||||
reInitEncodeParams: build_init_params(
|
||||
self.codec_guid,
|
||||
self.width,
|
||||
self.height,
|
||||
self.fps,
|
||||
&mut cfg,
|
||||
self.split_mode,
|
||||
self.session_async,
|
||||
@@ -1738,10 +1568,34 @@ impl Drop for NvencD3d11Encoder {
|
||||
}
|
||||
|
||||
/// Probe whether the active NVIDIA GPU can encode HEVC **4:4:4** (`NV_ENC_CAPS_SUPPORT_YUV444_ENCODE`).
|
||||
/// Creates a throwaway hardware D3D11 device + NVENC session, queries the cap, and tears down. HEVC-only;
|
||||
/// the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before* the Welcome
|
||||
/// so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a card without it).
|
||||
/// HEVC-only; the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before*
|
||||
/// the Welcome so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a
|
||||
/// card without it). See [`probe_encode_cap`] for the throwaway-session mechanics.
|
||||
pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
if codec != Codec::H265 {
|
||||
return false;
|
||||
}
|
||||
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE)
|
||||
}
|
||||
|
||||
/// Probe whether the active NVIDIA GPU can encode `codec` at **10-bit**
|
||||
/// (`NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` against the codec's own GUID — HEVC Main10 / AV1 10-bit).
|
||||
/// The result is cached by the caller ([`crate::encode::can_encode_10bit`]) and read *before* the
|
||||
/// Welcome so the negotiated bit depth — and the HDR label derived from it — matches what NVENC
|
||||
/// will really emit. The session-open path re-checks the same cap as a belt-and-braces guard
|
||||
/// ([`NvencD3d11Encoder::probe_caps`]'s 8-bit fallback).
|
||||
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
if !codec.supports_10bit() {
|
||||
return false;
|
||||
}
|
||||
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE)
|
||||
}
|
||||
|
||||
/// Query ONE NVENC capability for `codec`: creates a throwaway hardware D3D11 device + NVENC
|
||||
/// session on the **selected render adapter**, reads the cap, and tears everything down. `false`
|
||||
/// on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
|
||||
/// capability that couldn't be confirmed.
|
||||
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
|
||||
@@ -1750,9 +1604,6 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
if codec != Codec::H265 {
|
||||
return false;
|
||||
}
|
||||
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no".
|
||||
// This is also the `api()` gate for every NVENC call below.
|
||||
if try_api().is_err() {
|
||||
@@ -1762,8 +1613,9 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
|
||||
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
|
||||
// fills `device` or returns Err (→ false). `open_encode_session_ex` opens an NVENC session
|
||||
// against that device's raw pointer (valid while `device` is held) or errors (→ false, tearing
|
||||
// nothing down). `get_encode_caps` reads one scalar cap into `val` via the loaded API table.
|
||||
// against that device's raw pointer (valid while `device` is held) or errors (→ false, after
|
||||
// destroying any residue session the failed open left — the docs require it).
|
||||
// `get_encode_caps` reads one scalar cap into `val` via the loaded API table.
|
||||
// `destroy_encoder` frees the session exactly once; `device`/its context drop with the COM
|
||||
// wrappers. No handle escapes this call and nothing runs concurrently.
|
||||
unsafe {
|
||||
@@ -1816,15 +1668,19 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
.nv_ok()
|
||||
.is_err()
|
||||
{
|
||||
// Destroy-on-failed-open: a failed open may still hold a session slot.
|
||||
if !enc.is_null() {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let mut param = nv::NV_ENC_CAPS_PARAM {
|
||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE,
|
||||
capsToQuery: cap,
|
||||
reserved: [0; 62],
|
||||
};
|
||||
let mut val: i32 = 0;
|
||||
let ok = (api().get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
|
||||
let ok = (api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
|
||||
.nv_ok()
|
||||
.is_ok()
|
||||
&& val != 0;
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Host lifecycle event bus (scripting-and-hooks RFC §4, phase 0).
|
||||
//!
|
||||
//! A process-wide broadcast bus + bounded catch-up ring for **lifecycle events**: client
|
||||
//! connect/disconnect, session and stream start/end, pairing decisions, virtual-display
|
||||
//! create/release, library mutations, host start/stop. Fire sites on BOTH planes call
|
||||
//! [`emit`]; consumers ([`EventBus::subscribe`]) get a ring-backed catch-up plus a live
|
||||
//! tail — the shape `GET /api/v1/events` (SSE, phase 1) and the hook runner (phase 2)
|
||||
//! consume. Until those land, the bus is host-internal.
|
||||
//!
|
||||
//! Design notes (mirrors [`crate::log_capture`], the shipped ring precedent):
|
||||
//! - Events carry a monotonically increasing `seq` (1-based) and a wall-clock `ts_ms`.
|
||||
//! A consumer resumes with `since = last seen seq`; one that fell off the ring gets
|
||||
//! `dropped = true` and should resync via the REST snapshots.
|
||||
//! - The wire shape is **versioned and additive-only** within a major ([`SCHEMA_VERSION`]):
|
||||
//! fields and kinds may be added, never removed or renamed. The JSON snapshot tests below
|
||||
//! are the review gate — a failing snapshot IS a schema change.
|
||||
//! - **Payload hygiene: events never carry secrets** — no PINs, no tokens, no key material.
|
||||
//! Client names and fingerprints are fine (already exposed via the management API).
|
||||
//! - Emission is fire-and-forget and cheap (a mutex push + a non-blocking broadcast send);
|
||||
//! nothing here sits in a streaming hot path. Slow consumers lag (`RecvError::Lagged`)
|
||||
//! rather than buffering unboundedly; the SSE layer disconnects them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Wire-shape major version, carried on every event. Additive-only within a major; removing
|
||||
/// or renaming a field is a new major (and, at the API layer, a new endpoint negotiation).
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Catch-up ring capacity. Events are small (a few hundred bytes) and low-rate (lifecycle,
|
||||
/// not per-frame), so 1024 spans hours of ordinary host activity.
|
||||
const RING_CAPACITY: usize = 1024;
|
||||
/// Live-tail channel depth per subscriber before a slow consumer starts lagging.
|
||||
const BROADCAST_CAPACITY: usize = 256;
|
||||
|
||||
/// One host lifecycle event, as it will appear on the wire (`data:` of one SSE frame).
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
pub struct HostEvent {
|
||||
/// Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.
|
||||
pub seq: u64,
|
||||
/// Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).
|
||||
pub ts_ms: u64,
|
||||
/// Wire-shape version ([`SCHEMA_VERSION`]).
|
||||
pub schema: u32,
|
||||
/// The event kind + payload, flattened: `"kind": "stream.started", …payload…`.
|
||||
#[serde(flatten)]
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
/// Which protocol plane an event originated from. Hooks and scripts filter on it — a hook
|
||||
/// that fires for native clients but not Moonlight clients is a bug, not a v2 feature.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Plane {
|
||||
/// The native punktfunk/1 plane (QUIC).
|
||||
Native,
|
||||
/// The GameStream/Moonlight compat plane (`--gamestream`).
|
||||
Gamestream,
|
||||
}
|
||||
|
||||
/// Why a client went away. `Quit` is a deliberate user "stop" (the typed close code);
|
||||
/// `Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DisconnectReason {
|
||||
Quit,
|
||||
Timeout,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// The connecting/disconnecting client's identity.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
pub struct ClientRef {
|
||||
/// Client-supplied device name; may be empty (an anonymous or compat-plane client).
|
||||
pub name: String,
|
||||
/// Hex SHA-256 certificate fingerprint, when the client presented one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fingerprint: Option<String>,
|
||||
pub plane: Plane,
|
||||
}
|
||||
|
||||
/// A live A/V session (the plane-neutral notion the Dashboard shows).
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
pub struct SessionRef {
|
||||
/// Host-local session id (unique within this host process).
|
||||
pub id: u64,
|
||||
/// Short client label (cert-fingerprint prefix, or peer IP for an anonymous client).
|
||||
pub client: String,
|
||||
/// Negotiated mode, `WxH@Hz` (e.g. `"3840x2160@120"`).
|
||||
pub mode: String,
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
/// A live video stream (what the stream marker file reflects).
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
pub struct StreamRef {
|
||||
/// Negotiated mode, `WxH@Hz`.
|
||||
pub mode: String,
|
||||
pub hdr: bool,
|
||||
/// Client-supplied device name; may be empty.
|
||||
pub client: String,
|
||||
/// The launched app/title for this stream, when one was requested (store-qualified id on
|
||||
/// the native plane, app title on the GameStream plane).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app: Option<String>,
|
||||
pub plane: Plane,
|
||||
}
|
||||
|
||||
/// A device in the pairing flow.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
pub struct DeviceRef {
|
||||
/// Sanitized device name (the pairing store's copy).
|
||||
pub name: String,
|
||||
/// Hex certificate fingerprint.
|
||||
pub fingerprint: String,
|
||||
pub plane: Plane,
|
||||
}
|
||||
|
||||
/// The event catalog (RFC §4). Serialized internally tagged as `"kind": "<domain>.<verb>"`,
|
||||
/// flattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`].
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum EventKind {
|
||||
#[serde(rename = "client.connected")]
|
||||
ClientConnected { client: ClientRef },
|
||||
#[serde(rename = "client.disconnected")]
|
||||
ClientDisconnected {
|
||||
client: ClientRef,
|
||||
reason: DisconnectReason,
|
||||
},
|
||||
#[serde(rename = "session.started")]
|
||||
SessionStarted { session: SessionRef },
|
||||
#[serde(rename = "session.ended")]
|
||||
SessionEnded { session: SessionRef },
|
||||
#[serde(rename = "stream.started")]
|
||||
StreamStarted { stream: StreamRef },
|
||||
#[serde(rename = "stream.stopped")]
|
||||
StreamStopped { stream: StreamRef },
|
||||
#[serde(rename = "pairing.pending")]
|
||||
PairingPending { device: DeviceRef },
|
||||
#[serde(rename = "pairing.completed")]
|
||||
PairingCompleted { device: DeviceRef },
|
||||
#[serde(rename = "pairing.denied")]
|
||||
PairingDenied { device: DeviceRef },
|
||||
#[serde(rename = "display.created")]
|
||||
DisplayCreated {
|
||||
/// The virtual-display backend that minted it (`VirtualDisplay::name`).
|
||||
backend: String,
|
||||
/// `WxH@Hz`.
|
||||
mode: String,
|
||||
},
|
||||
#[serde(rename = "display.released")]
|
||||
DisplayReleased {
|
||||
/// How many kept displays this release retired.
|
||||
count: u32,
|
||||
},
|
||||
#[serde(rename = "library.changed")]
|
||||
LibraryChanged {
|
||||
/// What mutated the library: `"manual"` today; a provider id once the provider
|
||||
/// API (RFC §8) lands.
|
||||
source: String,
|
||||
},
|
||||
#[serde(rename = "host.started")]
|
||||
HostStarted {
|
||||
version: String,
|
||||
/// Whether the GameStream/Moonlight compat plane is enabled.
|
||||
gamestream: bool,
|
||||
},
|
||||
#[serde(rename = "host.stopping")]
|
||||
HostStopping,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
/// The wire kind string (`"stream.started"`, …) — for filters and log lines.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
EventKind::ClientConnected { .. } => "client.connected",
|
||||
EventKind::ClientDisconnected { .. } => "client.disconnected",
|
||||
EventKind::SessionStarted { .. } => "session.started",
|
||||
EventKind::SessionEnded { .. } => "session.ended",
|
||||
EventKind::StreamStarted { .. } => "stream.started",
|
||||
EventKind::StreamStopped { .. } => "stream.stopped",
|
||||
EventKind::PairingPending { .. } => "pairing.pending",
|
||||
EventKind::PairingCompleted { .. } => "pairing.completed",
|
||||
EventKind::PairingDenied { .. } => "pairing.denied",
|
||||
EventKind::DisplayCreated { .. } => "display.created",
|
||||
EventKind::DisplayReleased { .. } => "display.released",
|
||||
EventKind::LibraryChanged { .. } => "library.changed",
|
||||
EventKind::HostStarted { .. } => "host.started",
|
||||
EventKind::HostStopping => "host.stopping",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a mode as the wire's `WxH@Hz` string.
|
||||
pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
|
||||
format!("{width}x{height}@{hz}")
|
||||
}
|
||||
|
||||
/// One consumer's view: the ring-backed catch-up plus a live-tail receiver, taken atomically
|
||||
/// (no event can fall between `catch_up` and the first `rx.recv()`, and none is in both).
|
||||
pub struct Subscription {
|
||||
/// Events with `seq > since`, oldest first.
|
||||
pub catch_up: Vec<HostEvent>,
|
||||
/// True when events between `since` and the first caught-up one were already evicted —
|
||||
/// the consumer should resync via the REST snapshots (the `LogPage.dropped` contract).
|
||||
pub dropped: bool,
|
||||
/// The live tail. A consumer that can't keep up sees `RecvError::Lagged`.
|
||||
pub rx: broadcast::Receiver<HostEvent>,
|
||||
}
|
||||
|
||||
/// The process-wide event bus: a bounded seq-numbered ring (catch-up) + a broadcast channel
|
||||
/// (live tail).
|
||||
pub struct EventBus {
|
||||
inner: Mutex<Ring>,
|
||||
tx: broadcast::Sender<HostEvent>,
|
||||
}
|
||||
|
||||
struct Ring {
|
||||
events: VecDeque<HostEvent>,
|
||||
next_seq: u64,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
Self {
|
||||
inner: Mutex::new(Ring {
|
||||
events: VecDeque::with_capacity(RING_CAPACITY),
|
||||
next_seq: 1,
|
||||
}),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp, ring-buffer, and broadcast one event. Fire-and-forget: no receivers is fine
|
||||
/// (the ring still records it for a later subscriber's catch-up).
|
||||
pub fn emit(&self, kind: EventKind) {
|
||||
let ts_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let mut ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let ev = HostEvent {
|
||||
seq: ring.next_seq,
|
||||
ts_ms,
|
||||
schema: SCHEMA_VERSION,
|
||||
kind,
|
||||
};
|
||||
ring.next_seq += 1;
|
||||
if ring.events.len() == RING_CAPACITY {
|
||||
ring.events.pop_front();
|
||||
}
|
||||
ring.events.push_back(ev.clone());
|
||||
// Send while still holding the ring lock: it serializes with `subscribe` (which also
|
||||
// takes the lock), so an event lands either in a subscriber's catch-up or on its live
|
||||
// tail — never both, never neither. `send` is non-blocking, the hold is trivial.
|
||||
let _ = self.tx.send(ev);
|
||||
}
|
||||
|
||||
/// Subscribe with a resume cursor: events with `seq > since` come back as catch-up, the
|
||||
/// returned receiver carries everything after. `since = 0` means "from the ring start".
|
||||
pub fn subscribe(&self, since: u64) -> Subscription {
|
||||
let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let rx = self.tx.subscribe();
|
||||
let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq);
|
||||
let dropped = since != 0 && since + 1 < first_seq;
|
||||
let catch_up = ring
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.seq > since)
|
||||
.cloned()
|
||||
.collect();
|
||||
Subscription {
|
||||
catch_up,
|
||||
dropped,
|
||||
rx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide bus — a `OnceLock` singleton (the [`crate::log_capture::ring`] shape) so
|
||||
/// fire sites across both planes and the API layer share it without threading an `Arc`.
|
||||
pub fn bus() -> &'static EventBus {
|
||||
static BUS: OnceLock<EventBus> = OnceLock::new();
|
||||
BUS.get_or_init(EventBus::new)
|
||||
}
|
||||
|
||||
/// Emit one lifecycle event on the process-wide bus. Cheap and non-blocking — safe from any
|
||||
/// thread, including RAII `Drop` paths.
|
||||
pub fn emit(kind: EventKind) {
|
||||
bus().emit(kind);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ev(name: &str) -> EventKind {
|
||||
EventKind::LibraryChanged {
|
||||
source: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seq_is_monotonic_and_catch_up_resumes() {
|
||||
let bus = EventBus::new();
|
||||
for i in 0..5 {
|
||||
bus.emit(ev(&format!("m{i}")));
|
||||
}
|
||||
let sub = bus.subscribe(0);
|
||||
assert_eq!(
|
||||
sub.catch_up.iter().map(|e| e.seq).collect::<Vec<_>>(),
|
||||
vec![1, 2, 3, 4, 5]
|
||||
);
|
||||
assert!(!sub.dropped);
|
||||
assert!(sub.catch_up.iter().all(|e| e.schema == SCHEMA_VERSION));
|
||||
|
||||
// Resume from a cursor mid-ring.
|
||||
let sub = bus.subscribe(3);
|
||||
assert_eq!(
|
||||
sub.catch_up.iter().map(|e| e.seq).collect::<Vec<_>>(),
|
||||
vec![4, 5]
|
||||
);
|
||||
assert!(!sub.dropped);
|
||||
|
||||
// Cursor at the tip: empty catch-up, not a gap.
|
||||
let sub = bus.subscribe(5);
|
||||
assert!(sub.catch_up.is_empty());
|
||||
assert!(!sub.dropped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_reports_dropped() {
|
||||
let bus = EventBus::new();
|
||||
for i in 0..(RING_CAPACITY + 50) {
|
||||
bus.emit(ev(&format!("m{i}")));
|
||||
}
|
||||
// Seqs 1..=50 were evicted; a cursor inside the gap must flag it.
|
||||
let sub = bus.subscribe(10);
|
||||
assert!(sub.dropped);
|
||||
assert_eq!(sub.catch_up.first().map(|e| e.seq), Some(51));
|
||||
// A fresh consumer (since = 0) is a backfill, not a gap.
|
||||
let sub = bus.subscribe(0);
|
||||
assert!(!sub.dropped);
|
||||
assert_eq!(sub.catch_up.len(), RING_CAPACITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_tail_continues_exactly_after_catch_up() {
|
||||
let bus = EventBus::new();
|
||||
bus.emit(ev("before-1"));
|
||||
bus.emit(ev("before-2"));
|
||||
let mut sub = bus.subscribe(0);
|
||||
assert_eq!(sub.catch_up.len(), 2);
|
||||
// Emitted after subscribe → on the live tail only, starting at exactly seq 3.
|
||||
bus.emit(ev("after"));
|
||||
let live = sub.rx.recv().await.expect("live event");
|
||||
assert_eq!(live.seq, 3);
|
||||
assert_eq!(live.kind.name(), "library.changed");
|
||||
// Nothing duplicated: the tail holds only what wasn't in the catch-up.
|
||||
assert!(sub.rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
/// The wire shape IS the contract (additive-only, RFC §4): these snapshots are the review
|
||||
/// gate — if one fails, the change renames/removes a field and needs a schema-version bump,
|
||||
/// not a test update.
|
||||
#[test]
|
||||
fn wire_shape_snapshots() {
|
||||
let ev = HostEvent {
|
||||
seq: 4182,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
schema: 1,
|
||||
kind: EventKind::StreamStarted {
|
||||
stream: StreamRef {
|
||||
mode: mode_str(3840, 2160, 120),
|
||||
hdr: true,
|
||||
client: "Living Room TV".into(),
|
||||
app: Some("steam:570".into()),
|
||||
plane: Plane::Native,
|
||||
},
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":4182,"ts_ms":1700000000000,"schema":1,"kind":"stream.started","stream":{"mode":"3840x2160@120","hdr":true,"client":"Living Room TV","app":"steam:570","plane":"native"}}"#
|
||||
);
|
||||
|
||||
let ev = HostEvent {
|
||||
seq: 1,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
schema: 1,
|
||||
kind: EventKind::ClientDisconnected {
|
||||
client: ClientRef {
|
||||
name: "Deck".into(),
|
||||
fingerprint: Some("b1c2".into()),
|
||||
plane: Plane::Gamestream,
|
||||
},
|
||||
reason: DisconnectReason::Timeout,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":1,"ts_ms":1700000000000,"schema":1,"kind":"client.disconnected","client":{"name":"Deck","fingerprint":"b1c2","plane":"gamestream"},"reason":"timeout"}"#
|
||||
);
|
||||
|
||||
let ev = HostEvent {
|
||||
seq: 2,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
schema: 1,
|
||||
kind: EventKind::HostStopping,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_shape_roundtrips() {
|
||||
let ev = HostEvent {
|
||||
seq: 7,
|
||||
ts_ms: 3,
|
||||
schema: 1,
|
||||
kind: EventKind::PairingPending {
|
||||
device: DeviceRef {
|
||||
name: "iPad Pro".into(),
|
||||
fingerprint: "ab12".into(),
|
||||
plane: Plane::Native,
|
||||
},
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&ev).unwrap();
|
||||
let back: HostEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.seq, 7);
|
||||
assert_eq!(back.kind.name(), "pairing.pending");
|
||||
match back.kind {
|
||||
EventKind::PairingPending { device } => {
|
||||
assert_eq!(device.name, "iPad Pro");
|
||||
assert_eq!(device.plane, Plane::Native);
|
||||
}
|
||||
other => panic!("wrong kind: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,7 +262,7 @@ fn run(
|
||||
punktfunk_core::transport::grow_socket_buffers(&sock);
|
||||
// The client pings the audio port (~every 500ms) so we learn where to send.
|
||||
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
tracing::info!(port = AUDIO_PORT, "audio: awaiting client ping");
|
||||
tracing::debug!(port = AUDIO_PORT, "audio: awaiting client ping");
|
||||
let mut probe = [0u8; 256];
|
||||
let (_, client) = sock
|
||||
.recv_from(&mut probe)
|
||||
@@ -275,7 +275,7 @@ fn run(
|
||||
&sock,
|
||||
punktfunk_core::transport::MediaClass::Audio,
|
||||
);
|
||||
tracing::info!(%client, "audio: client endpoint learned");
|
||||
tracing::debug!(%client, "audio: client endpoint learned");
|
||||
|
||||
// Reuse the persistent capturer when its channel count still matches (drain stale
|
||||
// buffered audio); otherwise drop it (clean PipeWire teardown) and open at the new count.
|
||||
@@ -468,7 +468,7 @@ fn audio_body(
|
||||
timestamp = timestamp.wrapping_add(frame_ms as u32);
|
||||
sent += 1;
|
||||
if sent % 400 == 0 {
|
||||
tracing::info!(sent, "audio: streaming");
|
||||
tracing::debug!(sent, "audio: streaming");
|
||||
}
|
||||
|
||||
// Hold each frame to its packet-duration slot (skip if we've fallen behind a burst).
|
||||
|
||||
@@ -101,7 +101,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
},
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:?}"), "control: service error");
|
||||
tracing::warn!(error = ?e, "control: service error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
for wire in out {
|
||||
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
|
||||
{
|
||||
tracing::warn!(error = %format!("{e:?}"), "rumble send failed");
|
||||
tracing::warn!(error = ?e, "rumble send failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,12 +214,12 @@ fn on_receive(
|
||||
if inner == 0x0301 {
|
||||
if let Some((first, last)) = decode_rfi_range(&pt) {
|
||||
*state.rfi_range.lock().unwrap() = Some((first, last));
|
||||
tracing::info!(first, last, "control: RFI request → invalidate ref frames");
|
||||
tracing::debug!(first, last, "control: RFI request → invalidate ref frames");
|
||||
} else {
|
||||
state
|
||||
.force_idr
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
tracing::info!("control: RFI request (no range) → keyframe");
|
||||
tracing::debug!("control: RFI request (no range) → keyframe");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -227,8 +227,8 @@ fn on_receive(
|
||||
state
|
||||
.force_idr
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
tracing::info!(
|
||||
ty = format!("{inner:#06x}"),
|
||||
tracing::debug!(
|
||||
ty = %format_args!("{inner:#06x}"),
|
||||
"control: IDR request → keyframe"
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -61,7 +61,7 @@ pub struct GamepadFrame {
|
||||
// These are `pub const` aliases rather than a `pub use` re-export on purpose: on Windows the sole
|
||||
// consumer (the Linux uinput map) is cfg'd out, and an unused re-export lints as an error there,
|
||||
// whereas an unused `pub const` does not. The values still come only from core, so they can't drift;
|
||||
// the exact wire values are pinned by `punktfunk1.rs::gamepad_wire_bits_are_pinned`.
|
||||
// the exact wire values are pinned by `native.rs::gamepad_wire_bits_are_pinned`.
|
||||
use punktfunk_core::input::gamepad as wire;
|
||||
pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP;
|
||||
pub const BTN_DPAD_DOWN: u32 = wire::BTN_DPAD_DOWN;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user