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.
|
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||||
.flatpak-repo/
|
.flatpak-repo/
|
||||||
.flatpak-build/
|
.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",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
"libc",
|
"libc",
|
||||||
|
"punktfunk-core",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
"ureq",
|
"ureq",
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"windows-service",
|
"windows-service",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import android.view.Display
|
import android.view.Display
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -249,11 +251,25 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
|||||||
*/
|
*/
|
||||||
fun displaySupportsHdr(context: Context): Boolean {
|
fun displaySupportsHdr(context: Context): Boolean {
|
||||||
val display = runCatching { context.display }.getOrNull() ?: return false
|
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||||
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
|
val types = buildSet {
|
||||||
val caps = display.hdrCapabilities ?: return false
|
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
|
||||||
return caps.supportedHdrTypes.any {
|
// 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
|
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. */
|
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ internal fun StatsOverlay(
|
|||||||
s: DoubleArray,
|
s: DoubleArray,
|
||||||
verbosity: StatsVerbosity,
|
verbosity: StatsVerbosity,
|
||||||
decoderLabel: String = "",
|
decoderLabel: String = "",
|
||||||
|
codecLabel: String = "",
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||||
@@ -66,7 +67,7 @@ internal fun StatsOverlay(
|
|||||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||||
}
|
}
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
videoFeedLine(s, codecLabel)?.let { statLine(it, Color.White) }
|
||||||
}
|
}
|
||||||
if (latValid) {
|
if (latValid) {
|
||||||
// Display stage (s[22]–s[25], from OnFrameRendered): when a render timestamp landed
|
// 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
|
* Format the negotiated video-feed descriptor from [codecLabel] plus the trailing four stats
|
||||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
* 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)
|
* `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
|
* 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
|
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4.
|
||||||
* Android decoder is always HEVC (`video/hevc`).
|
* [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
|
if (s.size < 14) return null
|
||||||
val bitDepth = s[10].toInt()
|
val bitDepth = s[10].toInt()
|
||||||
val primaries = s[11].toInt()
|
val primaries = s[11].toInt()
|
||||||
@@ -175,5 +177,6 @@ private fun videoFeedLine(s: DoubleArray): String? {
|
|||||||
2 -> "4:2:2"
|
2 -> "4:2:2"
|
||||||
else -> "4:2:0"
|
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() }
|
val initialSettings = remember { SettingsStore(context).load() }
|
||||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||||
var decoderLabel by remember { mutableStateOf("") }
|
var decoderLabel by remember { mutableStateOf("") }
|
||||||
|
var codecLabel by remember { mutableStateOf("") }
|
||||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
// 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) {
|
LaunchedEffect(handle, statsOn) {
|
||||||
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||||
if (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) {
|
while (true) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
stats = NativeBridge.nativeVideoStats(handle)
|
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.
|
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||||
if (statsOn) {
|
if (statsOn) {
|
||||||
stats?.let {
|
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
|
// "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,
|
verbosity = verbosity,
|
||||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||||
|
codecLabel = "HEVC",
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ object NativeBridge {
|
|||||||
*/
|
*/
|
||||||
external fun nativeVideoMime(handle: Long): String
|
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
|
* 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
|
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ fn run_sync(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log::info!(
|
log::info!(
|
||||||
"decode: HEVC decoder started at {}x{}",
|
"decode: {mime} decoder started at {}x{}",
|
||||||
mode.width,
|
mode.width,
|
||||||
mode.height
|
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`
|
/// 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_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).
|
/// (`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| {
|
on_error: Some(Box::new(move |e, code, _detail| {
|
||||||
let fatal = !code.is_recoverable() && !code.is_transient();
|
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 });
|
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
|
/// `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.
|
/// 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
|
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||||||
|
|||||||
@@ -19,5 +19,32 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>_punktfunk._udp</string>
|
<string>_punktfunk._udp</string>
|
||||||
</array>
|
</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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -73,5 +73,15 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||||
</array>
|
</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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -20,5 +20,14 @@
|
|||||||
is true on iOS/tvOS too. -->
|
is true on iOS/tvOS too. -->
|
||||||
<key>com.apple.developer.networking.multicast</key>
|
<key>com.apple.developer.networking.multicast</key>
|
||||||
<true/>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -9,13 +9,20 @@ let package = Package(
|
|||||||
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],
|
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "PunktfunkKit", targets: ["PunktfunkKit"]),
|
.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"]),
|
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
||||||
|
// No dependencies by design — an extension process links this alone.
|
||||||
|
.target(name: "PunktfunkShared"),
|
||||||
.target(
|
.target(
|
||||||
name: "PunktfunkKit",
|
name: "PunktfunkKit",
|
||||||
dependencies: ["PunktfunkCore"],
|
dependencies: ["PunktfunkCore", "PunktfunkShared"],
|
||||||
// OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the
|
// 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 target) so it rides along via Bundle.module in both `swift build` and the Xcode
|
||||||
// app, which links the PunktfunkKit product. Refresh with
|
// 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
|
// 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.
|
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"],
|
name: "PunktfunkKitTests",
|
||||||
|
dependencies: ["PunktfunkKit", "PunktfunkShared", "PunktfunkCore"],
|
||||||
resources: [
|
resources: [
|
||||||
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
|
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
|
||||||
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
|
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
|
||||||
|
|||||||
@@ -11,14 +11,56 @@
|
|||||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
||||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
||||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
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 */
|
/* 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 */
|
/* Begin PBXFileReference section */
|
||||||
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
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; };
|
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 */
|
/* 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 */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
AA0000000000000000000002 /* App */ = {
|
AA0000000000000000000002 /* App */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
@@ -30,6 +72,14 @@
|
|||||||
path = Sources/PunktfunkClient;
|
path = Sources/PunktfunkClient;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E295569D300948B9009F939C /* PunktfunkWidgets */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */,
|
||||||
|
);
|
||||||
|
path = PunktfunkWidgets;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -58,14 +108,27 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
AA0000000000000000000007 = {
|
AA0000000000000000000007 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */,
|
||||||
AA0000000000000000000002 /* App */,
|
AA0000000000000000000002 /* App */,
|
||||||
AA0000000000000000000003 /* Sources/PunktfunkClient */,
|
AA0000000000000000000003 /* Sources/PunktfunkClient */,
|
||||||
|
E295569D300948B9009F939C /* PunktfunkWidgets */,
|
||||||
|
E2955698300948B9009F939C /* Frameworks */,
|
||||||
AA0000000000000000000008 /* Products */,
|
AA0000000000000000000008 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -76,10 +139,20 @@
|
|||||||
AA0000000000000000000001 /* Punktfunk.app */,
|
AA0000000000000000000001 /* Punktfunk.app */,
|
||||||
BB0000000000000000000001 /* Punktfunk-iOS.app */,
|
BB0000000000000000000001 /* Punktfunk-iOS.app */,
|
||||||
CC0000000000000000000001 /* Punktfunk-tvOS.app */,
|
CC0000000000000000000001 /* Punktfunk-tvOS.app */,
|
||||||
|
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E2955698300948B9009F939C /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E2955699300948B9009F939C /* WidgetKit.framework */,
|
||||||
|
E295569B300948B9009F939C /* SwiftUI.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -114,10 +187,12 @@
|
|||||||
BB000000000000000000000B /* Sources */,
|
BB000000000000000000000B /* Sources */,
|
||||||
BB0000000000000000000004 /* Frameworks */,
|
BB0000000000000000000004 /* Frameworks */,
|
||||||
BB000000000000000000000C /* Resources */,
|
BB000000000000000000000C /* Resources */,
|
||||||
|
E29556AA300948BA009F939C /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
E29556A8300948BA009F939C /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
AA0000000000000000000002 /* App */,
|
AA0000000000000000000002 /* App */,
|
||||||
@@ -156,6 +231,29 @@
|
|||||||
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -163,11 +261,15 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2700;
|
||||||
LastUpgradeCheck = 2700;
|
LastUpgradeCheck = 2700;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
AA0000000000000000000009 = {
|
AA0000000000000000000009 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
};
|
};
|
||||||
|
E2955696300948B9009F939C = {
|
||||||
|
CreatedOnToolsVersion = 27.0;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = AA000000000000000000000E /* Build configuration list for PBXProject "Punktfunk" */;
|
buildConfigurationList = AA000000000000000000000E /* Build configuration list for PBXProject "Punktfunk" */;
|
||||||
@@ -190,6 +292,7 @@
|
|||||||
AA0000000000000000000009 /* Punktfunk */,
|
AA0000000000000000000009 /* Punktfunk */,
|
||||||
BB0000000000000000000009 /* Punktfunk-iOS */,
|
BB0000000000000000000009 /* Punktfunk-iOS */,
|
||||||
CC0000000000000000000009 /* Punktfunk-tvOS */,
|
CC0000000000000000000009 /* Punktfunk-tvOS */,
|
||||||
|
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -216,6 +319,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
E2955695300948B9009F939C /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -240,8 +350,23 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
E2955693300948B9009F939C /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
E29556A8300948BA009F939C /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
|
||||||
|
targetProxy = E29556A7300948BA009F939C /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
AA0000000000000000000010 /* Debug */ = {
|
AA0000000000000000000010 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -564,6 +689,97 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -603,6 +819,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
E29556AB300948BA009F939C /* Debug */,
|
||||||
|
E29556AC300948BA009F939C /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
@@ -636,6 +861,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = PunktfunkKit;
|
productName = PunktfunkKit;
|
||||||
};
|
};
|
||||||
|
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = PunktfunkShared;
|
||||||
|
};
|
||||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
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
|
@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?
|
@State private var pairingTarget: StoredHost?
|
||||||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
/// 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).
|
/// 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
|
/// 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.
|
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
@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 {
|
private var gamepadUIActive: Bool {
|
||||||
GamepadUIEnvironment.isActive(
|
GamepadUIEnvironment.isActive(
|
||||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||||
@@ -113,7 +130,62 @@ struct ContentView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
seedDefaultModeIfNeeded()
|
seedDefaultModeIfNeeded()
|
||||||
autoConnectIfAsked()
|
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
|
.onChange(of: model.phase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .streaming:
|
case .streaming:
|
||||||
@@ -262,6 +334,57 @@ struct ContentView: View {
|
|||||||
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||||
+ "approve it — no need to reconnect.")
|
+ "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 {
|
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 }
|
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
|
/// `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
|
/// 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`
|
/// — `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.
|
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
||||||
func confirmTrust() -> Data? {
|
func confirmTrust() -> Data? {
|
||||||
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
|
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
|
||||||
@@ -349,6 +401,11 @@ final class SessionModel: ObservableObject {
|
|||||||
func disconnect(deliberate: Bool = true) {
|
func disconnect(deliberate: Bool = true) {
|
||||||
statsTimer?.invalidate()
|
statsTimer?.invalidate()
|
||||||
statsTimer = nil
|
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
|
let audio = self.audio
|
||||||
self.audio = nil
|
self.audio = nil
|
||||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
// 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 {
|
@ViewBuilder var experimentalSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Show game library", isOn: $libraryEnabled)
|
Toggle("Show game library", isOn: $libraryEnabled)
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ struct SettingsView: View {
|
|||||||
@ObservedObject var gamepads = GamepadManager.shared
|
@ObservedObject var gamepads = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||||
|
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
@State var showControllerTest = false
|
@State var showControllerTest = false
|
||||||
#endif
|
#endif
|
||||||
@@ -242,6 +244,7 @@ struct SettingsView: View {
|
|||||||
pointerSection
|
pointerSection
|
||||||
compositorSection
|
compositorSection
|
||||||
wakeSection
|
wakeSection
|
||||||
|
keepAliveSection // iOS-only content; empty on tvOS
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("General")
|
.navigationTitle("General")
|
||||||
|
|||||||
@@ -11,32 +11,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(WidgetKit)
|
||||||
|
import WidgetKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct StoredHost: Identifiable, Codable, Hashable {
|
// `StoredHost` (the model + its JSON codec) now lives in PunktfunkShared so the widget extension
|
||||||
var id = UUID()
|
// can read the same store; PunktfunkKit re-exports it. The discovery-join helpers below stay here
|
||||||
var name: String
|
// because they reference PunktfunkKit's `DiscoveredHost`/`HostDiscovery`.
|
||||||
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 ?? [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StoredHost {
|
extension StoredHost {
|
||||||
/// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host — drives the
|
/// 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).
|
/// never advertises still reads Online. Not persisted (it's live reachability, not config).
|
||||||
@Published var probedOnline: Set<StoredHost.ID> = []
|
@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() {
|
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) {
|
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
|
||||||
hosts = decoded
|
hosts = decoded
|
||||||
} else {
|
} 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) {
|
func add(_ host: StoredHost) {
|
||||||
hosts.append(host)
|
hosts.append(host)
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,7 @@ final class HostStore: ObservableObject {
|
|||||||
|
|
||||||
func markConnected(_ hostID: UUID) {
|
func markConnected(_ hostID: UUID) {
|
||||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
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
|
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
|
||||||
@@ -158,7 +159,17 @@ final class HostStore: ObservableObject {
|
|||||||
|
|
||||||
private func persist() {
|
private func persist() {
|
||||||
if let data = try? JSONEncoder().encode(hosts) {
|
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)
|
// MARK: - Playback (host → speaker)
|
||||||
|
|
||||||
private func startPlayback(speakerUID: String) {
|
private func startPlayback(speakerUID: String) {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
||||||
|
|
||||||
import Foundation
|
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).
|
/// Cover art URLs (the public Steam CDN for Steam titles, user-supplied for custom entries).
|
||||||
public struct Artwork: Codable, Hashable, Sendable {
|
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.
|
/// Stateless fetcher for a host's library.
|
||||||
public enum LibraryClient {
|
public enum LibraryClient {
|
||||||
/// `GET https://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
|
/// `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)
|
_ = 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
|
/// 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 —
|
/// 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
|
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import Combine
|
|||||||
import CoreHaptics
|
import CoreHaptics
|
||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
public final class GamepadFeedback {
|
public final class GamepadFeedback {
|
||||||
private let connection: PunktfunkConnection
|
private let connection: PunktfunkConnection
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class GamepadManager: ObservableObject {
|
public final class GamepadManager: ObservableObject {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
public enum GamepadUIEnvironment {
|
public enum GamepadUIEnvironment {
|
||||||
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import Foundation
|
import Foundation
|
||||||
import PunktfunkCore
|
import PunktfunkCore
|
||||||
|
import PunktfunkShared
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// How touchscreen fingers drive the host — persisted under `DefaultsKey.touchMode`, latched
|
/// 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.
|
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
||||||
/// rename the cases freely, never the strings.
|
/// rename the cases freely, never the strings.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkShared
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Metal
|
import Metal
|
||||||
|
import PunktfunkShared
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
/// 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 {
|
while alive, !token.isStopped {
|
||||||
alive = autoreleasepool { () -> Bool in
|
alive = autoreleasepool { () -> Bool in
|
||||||
do {
|
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
|
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||||
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
// 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
|
// 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 {
|
while alive, !token.isStopped {
|
||||||
alive = autoreleasepool { () -> Bool in
|
alive = autoreleasepool { () -> Bool in
|
||||||
do {
|
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 }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let newest = newestIndex,
|
if let newest = newestIndex,
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ final class StreamPump {
|
|||||||
while alive, !token.isStopped {
|
while alive, !token.isStopped {
|
||||||
alive = autoreleasepool { () -> Bool in
|
alive = autoreleasepool { () -> Bool in
|
||||||
do {
|
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
|
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||||
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import PunktfunkShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import GameController
|
import GameController
|
||||||
import PunktfunkCore
|
import PunktfunkCore
|
||||||
|
import PunktfunkShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import os
|
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
|
// 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
|
// 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
|
// 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
|
import Foundation
|
||||||
|
|
||||||
@@ -111,6 +112,16 @@ public enum DefaultsKey {
|
|||||||
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
|
/// 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.
|
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
|
||||||
public static let autoWake = "punktfunk.autoWake"
|
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 {
|
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
|
/// menus) — it exists so the menu item is honest whenever it CAN fire, and as the shortcut's
|
||||||
/// discoverable menu-bar surface.
|
/// discoverable menu-bar surface.
|
||||||
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
|
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 || {
|
.spawn(move || {
|
||||||
if let Err(e) = render_thread(pcm_rx, recycle_tx, stop_t, ready_tx, channels as u8)
|
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")?;
|
.context("spawn audio thread")?;
|
||||||
@@ -232,7 +232,7 @@ impl MicStreamer {
|
|||||||
.name("punktfunk-mic".into())
|
.name("punktfunk-mic".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = mic_thread(&connector, stop_t) {
|
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")?;
|
.context("spawn mic thread")?;
|
||||||
|
|||||||
@@ -1493,7 +1493,7 @@ impl Worker {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed")
|
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 = (low, high);
|
||||||
slot.rumble.last_at = Some(Instant::now());
|
slot.rumble.last_at = Some(Instant::now());
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ pub fn agent(
|
|||||||
.with_safe_default_protocol_versions()
|
.with_safe_default_protocol_versions()
|
||||||
.map_err(|e| bad("tls config", &e))?
|
.map_err(|e| bad("tls config", &e))?
|
||||||
.dangerous()
|
.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())
|
let cert = rustls::pki_types::CertificateDer::from_pem_slice(identity.0.as_bytes())
|
||||||
.map_err(|e| bad("client cert pem", &e))?;
|
.map_err(|e| bad("client cert pem", &e))?;
|
||||||
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(identity.1.as_bytes())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ fn pump(
|
|||||||
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
|
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
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]);
|
buf.extend_from_slice(&pcm[..n]);
|
||||||
player.push(buf);
|
player.push(buf);
|
||||||
}
|
}
|
||||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
Err(e) => tracing::debug!(error = %e, "opus decode failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {}
|
Err(PunktfunkError::NoFrame) => {}
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ impl Decoder {
|
|||||||
if choice == "vaapi" {
|
if choice == "vaapi" {
|
||||||
return Err(e.context("PUNKTFUNK_DECODER=vaapi but VAAPI failed"));
|
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.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||||
self.vaapi_fails = 0;
|
self.vaapi_fails = 0;
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(error = %e,
|
tracing::debug!(backend = which, error = %e,
|
||||||
"{which} decode error — requesting keyframe, keeping hardware decode");
|
"decode error — requesting keyframe, keeping hardware decode");
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
@@ -1653,7 +1653,7 @@ unsafe extern "C" fn pick_vulkan(
|
|||||||
&mut fr,
|
&mut fr,
|
||||||
);
|
);
|
||||||
if r < 0 || fr.is_null() {
|
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;
|
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||||
}
|
}
|
||||||
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
|
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
|
||||||
@@ -1665,7 +1665,7 @@ unsafe extern "C" fn pick_vulkan(
|
|||||||
as _;
|
as _;
|
||||||
let r = ffi::av_hwframe_ctx_init(fr);
|
let r = ffi::av_hwframe_ctx_init(fr);
|
||||||
if r < 0 {
|
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;
|
let mut fr = fr;
|
||||||
ffi::av_buffer_unref(&mut fr);
|
ffi::av_buffer_unref(&mut fr);
|
||||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||||
|
|||||||
@@ -537,7 +537,8 @@ impl PyroWaveDecoder {
|
|||||||
let fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
|
let fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
mode = %format!("{width}x{height}"),
|
width,
|
||||||
|
height,
|
||||||
"PyroWave decoder open on the presenter's device (compute iDWT, BT.709 limited)"
|
"PyroWave decoder open on the presenter's device (compute iDWT, BT.709 limited)"
|
||||||
);
|
);
|
||||||
Ok(PyroWaveDecoder {
|
Ok(PyroWaveDecoder {
|
||||||
@@ -602,8 +603,8 @@ impl PyroWaveDecoder {
|
|||||||
});
|
});
|
||||||
self.next = 0;
|
self.next = 0;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
from = %format!("{}x{}", self.width, self.height),
|
from = %format_args!("{}x{}", self.width, self.height),
|
||||||
to = %format!("{width}x{height}"),
|
to = %format_args!("{width}x{height}"),
|
||||||
"PyroWave decoder rebuilt for mid-stream resize"
|
"PyroWave decoder rebuilt for mid-stream resize"
|
||||||
);
|
);
|
||||||
self.width = width;
|
self.width = width;
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ pub fn import(
|
|||||||
// EGL could leave an INVALID modifier to the driver's implied choice; explicit-
|
// 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).
|
// modifier images can't — LINEAR is the only honest guess (debug-visible if wrong).
|
||||||
let modifier = if frame.modifier == DRM_FORMAT_MOD_INVALID {
|
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
|
DRM_FORMAT_MOD_LINEAR
|
||||||
} else {
|
} else {
|
||||||
frame.modifier
|
frame.modifier
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ struct StreamState {
|
|||||||
// Hardware-path health: a failure streak (or a device with no import support at
|
// 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.
|
// all) demotes the decoder to software via the shared flag — once per session.
|
||||||
dmabuf_demoted: bool,
|
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,
|
hw_fails: u32,
|
||||||
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
|
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
|
||||||
osd_text: String,
|
osd_text: String,
|
||||||
@@ -255,6 +260,8 @@ impl StreamState {
|
|||||||
win_start: Instant::now(),
|
win_start: Instant::now(),
|
||||||
presented: PresentedWindow::default(),
|
presented: PresentedWindow::default(),
|
||||||
dmabuf_demoted: false,
|
dmabuf_demoted: false,
|
||||||
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
|
pyro_present_warned: false,
|
||||||
hw_fails: 0,
|
hw_fails: 0,
|
||||||
osd_text: String::new(),
|
osd_text: String::new(),
|
||||||
last_stats: None,
|
last_stats: None,
|
||||||
@@ -542,7 +549,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
fullscreen = !fullscreen;
|
fullscreen = !fullscreen;
|
||||||
tracing::debug!(fullscreen, "fullscreen toggle");
|
tracing::debug!(fullscreen, "fullscreen toggle");
|
||||||
if let Err(e) = window.set_fullscreen(fullscreen) {
|
if let Err(e) = window.set_fullscreen(fullscreen) {
|
||||||
tracing::warn!(error = %e, "fullscreen toggle");
|
tracing::warn!(error = %e, fullscreen, "failed to toggle fullscreen");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -985,13 +992,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
FrameInput::PyroWave(f),
|
FrameInput::PyroWave(f),
|
||||||
overlay_frame.as_ref(),
|
overlay_frame.as_ref(),
|
||||||
) {
|
) {
|
||||||
Ok(p) => p,
|
Ok(p) => {
|
||||||
|
st.pyro_present_warned = false;
|
||||||
|
p
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if device_lost(&e) {
|
if device_lost(&e) {
|
||||||
return Err(e)
|
return Err(e)
|
||||||
.context("GPU device lost — the session cannot continue");
|
.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
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
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,
|
# 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.
|
# 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]
|
[dependencies]
|
||||||
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
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 => {}
|
ResyncStep::Idle => {}
|
||||||
}
|
}
|
||||||
} else {
|
} 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 reject;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
#[cfg(feature = "tls")]
|
||||||
|
pub mod tls;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
pub mod wol;
|
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};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
|
/// 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()))
|
certs.first().map(|c| cert_fingerprint(c.as_ref()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
|
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. The implementation is
|
||||||
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
/// [`crate::tls::cert_fingerprint`] (shared with the HTTP clients); re-exported here so callers
|
||||||
use sha2::Digest;
|
/// reaching it as `quic::endpoint::cert_fingerprint` keep working.
|
||||||
sha2::Sha256::digest(cert_der).into()
|
pub use crate::tls::cert_fingerprint;
|
||||||
}
|
|
||||||
|
|
||||||
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
|
/// 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).
|
/// 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 _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
let builder = rustls::ClientConfig::builder()
|
let builder = rustls::ClientConfig::builder()
|
||||||
.dangerous()
|
.dangerous()
|
||||||
.with_custom_certificate_verifier(Arc::new(PinVerify {
|
.with_custom_certificate_verifier(Arc::new(crate::tls::PinVerify::with_observed(
|
||||||
pin,
|
pin,
|
||||||
observed: observed.clone(),
|
observed.clone(),
|
||||||
}));
|
)));
|
||||||
let mut rustls_cfg = match identity {
|
let mut rustls_cfg = match identity {
|
||||||
None => builder.with_no_client_auth(),
|
None => builder.with_no_client_auth(),
|
||||||
Some((cert_pem, key_pem)) => {
|
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
|
/// 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
|
/// verify the handshake signature for real — possession of the presented cert's key is
|
||||||
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
|
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
|
||||||
@@ -294,69 +293,3 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
|
|||||||
.supported_schemes()
|
.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).
|
/// 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>> {
|
pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> {
|
||||||
let mut len = [0u8; 2];
|
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 crate::error::{PunktfunkError, Result};
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use spake2::{Ed25519Group, Identity, Password, Spake2};
|
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
|
/// 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
|
/// 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 —
|
/// 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.)
|
/// immediate microburst rather than a whole unpaced frame.)
|
||||||
pub(crate) const TARGET_SOCKBUF: usize = 32 * 1024 * 1024;
|
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
|
//! ([`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`])
|
//! `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
|
//! — 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
|
//! NIC doesn't drop a line-rate burst. All three layer on this same [`Transport`] seam (scalar
|
||||||
//! fallbacks for loopback and the remaining targets).
|
//! fallbacks for loopback and the remaining targets).
|
||||||
|
|
||||||
@@ -230,8 +230,7 @@ mod uso {
|
|||||||
STATE.store(if off { 2 } else { 1 }, Ordering::Relaxed);
|
STATE.store(if off { 2 } else { 1 }, Ordering::Relaxed);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
enabled = !off,
|
enabled = !off,
|
||||||
"Windows UDP Send Offload (USO): {} (the 1 Gbps+ send lever; PUNKTFUNK_GSO=0 disables)",
|
"Windows UDP Send Offload (USO) resolved (the 1 Gbps+ send lever; PUNKTFUNK_GSO=0 disables)"
|
||||||
if off { "off" } else { "on" }
|
|
||||||
);
|
);
|
||||||
!off
|
!off
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,9 @@ windows = { version = "0.62", features = [
|
|||||||
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||||
"Win32_System_Diagnostics_Debug",
|
"Win32_System_Diagnostics_Debug",
|
||||||
"Win32_System_Kernel",
|
"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
|
# 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
|
# 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)
|
inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4)
|
||||||
audio/ · audio.rs Opus out + virtual mic (PipeWire / WASAPI)
|
audio/ · audio.rs Opus out + virtual mic (PipeWire / WASAPI)
|
||||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
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
|
mgmt.rs · native_pairing.rs · stats_recorder.rs management API, pairing, perf capture
|
||||||
hdr.rs · library.rs HDR metadata; multi-store game library
|
hdr.rs · library.rs HDR metadata; multi-store game library
|
||||||
linux/ · windows/ platform-confined backends
|
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")
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "audio/windows/audio_control.rs"]
|
#[path = "audio/windows/audio_control.rs"]
|
||||||
mod audio_control;
|
mod audio_control;
|
||||||
@@ -335,211 +121,5 @@ mod wasapi_mic;
|
|||||||
#[path = "audio/wiring_plan.rs"]
|
#[path = "audio/wiring_plan.rs"]
|
||||||
pub(crate) mod wiring_plan;
|
pub(crate) mod wiring_plan;
|
||||||
|
|
||||||
#[cfg(test)]
|
mod mic_pump;
|
||||||
mod pump_tests {
|
pub use mic_pump::MicPump;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ impl PwMicSource {
|
|||||||
.name("punktfunk-pw-mic".into())
|
.name("punktfunk-pw-mic".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = mic_pw_thread(pcm_rx, quit_rx, channels, flush_t, ready_tx) {
|
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.
|
// Whether a clean quit or a daemon death: this instance is done — the pump reopens.
|
||||||
alive_t.store(false, Ordering::Release);
|
alive_t.store(false, Ordering::Release);
|
||||||
@@ -322,7 +325,7 @@ fn mic_pw_thread(
|
|||||||
.state_changed({
|
.state_changed({
|
||||||
let mainloop = mainloop.clone();
|
let mainloop = mainloop.clone();
|
||||||
move |_s, _ud, old, new| {
|
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.
|
// A stream error is unrecoverable for this instance — exit so the pump reopens.
|
||||||
if matches!(new, pw::stream::StreamState::Error(_)) {
|
if matches!(new, pw::stream::StreamState::Error(_)) {
|
||||||
mainloop.quit();
|
mainloop.quit();
|
||||||
@@ -522,7 +525,7 @@ fn pw_thread(
|
|||||||
let _listener = stream
|
let _listener = stream
|
||||||
.add_local_listener_with_user_data(tx)
|
.add_local_listener_with_user_data(tx)
|
||||||
.state_changed(|_s, _ud, old, new| {
|
.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| {
|
.param_changed(|_stream, _tx, id, param| {
|
||||||
let Some(param) = param else { return };
|
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())
|
.name("punktfunk-wasapi-audio".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
|
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")?;
|
.context("spawn wasapi audio thread")?;
|
||||||
|
|||||||
@@ -650,8 +650,6 @@ mod pipewire {
|
|||||||
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
/// 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.
|
/// so the GPU encoder re-uploads its cursor texture only on change.
|
||||||
serial: u64,
|
serial: u64,
|
||||||
/// One-shot guard for the "cursor present but this frame is zero-copy" notice.
|
|
||||||
warned_zerocopy: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CursorState {
|
impl CursorState {
|
||||||
@@ -1174,22 +1172,6 @@ mod pipewire {
|
|||||||
if ud.broken.load(Ordering::Relaxed) {
|
if ud.broken.load(Ordering::Relaxed) {
|
||||||
return;
|
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
|
// 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
|
// 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
|
// 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);
|
std::sync::atomic::AtomicBool::new(true);
|
||||||
if F2.swap(false, Ordering::Relaxed) {
|
if F2.swap(false, Ordering::Relaxed) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %format!("{e}"),
|
error = %e,
|
||||||
"dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \
|
"dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \
|
||||||
zero-copy may show stale frames (no producer explicit sync)"
|
zero-copy may show stale frames (no producer explicit sync)"
|
||||||
);
|
);
|
||||||
@@ -1915,7 +1897,15 @@ mod pipewire {
|
|||||||
unsafe { stream.queue_raw_buffer(newest) };
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
}));
|
}));
|
||||||
if outcome.is_err() {
|
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()
|
.register()
|
||||||
@@ -1930,7 +1920,11 @@ mod pipewire {
|
|||||||
|
|
||||||
// Request raw video in any encoder-mappable layout, any size/framerate.
|
// Request raw video in any encoder-mappable layout, any size/framerate.
|
||||||
let obj = if let Some((fw, fh)) = fixed_pod {
|
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::pod::object!(
|
||||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||||
pw::spa::param::ParamType::EnumFormat,
|
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.
|
// 100% DuplicateOutput1 E_ACCESSDENIED is diagnosable instead of silent.
|
||||||
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
|
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
|
||||||
Ok(()) => tracing::info!("DPI awareness set: 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"),
|
"SetProcessDpiAwarenessContext failed (already set?) — DuplicateOutput1 may E_ACCESSDENIED"),
|
||||||
}
|
}
|
||||||
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). DuplicateOutput1 needs 2.
|
// 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
|
/// LAST-RESORT fallback: nudge DWM into composing THE TARGET virtual display. DWM presents a
|
||||||
/// DIRTIES it — an idle desktop never does, so a freshly-attached ring (session open, or a
|
/// display only when something DIRTIES it — an idle desktop never does, so a freshly-attached ring
|
||||||
/// mid-session ring recreate) can sit at E_PENDING with no first frame even though everything is
|
/// (session open, or a mid-session ring recreate) can sit at E_PENDING with no first frame even
|
||||||
/// healthy. pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
|
/// 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
|
/// the frame — a guaranteed real present onto the IDD swap-chain (empirically what
|
||||||
/// `punktfunk-probe --input-test` always relied on).
|
/// `punktfunk-probe --input-test` always relied on).
|
||||||
///
|
///
|
||||||
@@ -595,7 +605,7 @@ impl DescriptorPoller {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
// Degraded, not fatal: the session streams, it just never follows a mid-session
|
// 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).
|
// 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();
|
.ok();
|
||||||
Self { snap, stop, thread }
|
Self { snap, stop, thread }
|
||||||
@@ -753,6 +763,13 @@ pub struct IddPushCapturer {
|
|||||||
/// during active flow and warns when they turn metronomic — the sole-virtual-display
|
/// during active flow and warns when they turn metronomic — the sole-virtual-display
|
||||||
/// periodic-stutter diagnostic.
|
/// periodic-stutter diagnostic.
|
||||||
stall_watch: StallWatch,
|
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
|
/// 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
|
/// 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()`:
|
/// 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,
|
want_444: bool,
|
||||||
keepalive: Box<dyn Send>,
|
keepalive: Box<dyn Send>,
|
||||||
) -> std::result::Result<Self, (anyhow::Error, 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) {
|
match Self::open_inner(target, preferred, client_10bit, want_444) {
|
||||||
Ok(mut me) => {
|
Ok(mut me) => {
|
||||||
me._keepalive = keepalive;
|
me._keepalive = keepalive;
|
||||||
@@ -1015,6 +1035,19 @@ impl IddPushCapturer {
|
|||||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||||
let display_hdr = enabled_hdr
|
let display_hdr = enabled_hdr
|
||||||
|| crate::win_display::advanced_color_enabled(target.target_id).unwrap_or(false);
|
|| 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 {
|
let ring_fmt = if display_hdr {
|
||||||
DXGI_FORMAT_R16G16B16A16_FLOAT
|
DXGI_FORMAT_R16G16B16A16_FLOAT
|
||||||
} else {
|
} else {
|
||||||
@@ -1146,6 +1179,8 @@ impl IddPushCapturer {
|
|||||||
last_liveness: Instant::now(),
|
last_liveness: Instant::now(),
|
||||||
last_kick: Instant::now(),
|
last_kick: Instant::now(),
|
||||||
stall_watch: StallWatch::new(),
|
stall_watch: StallWatch::new(),
|
||||||
|
stalls_seen: 0,
|
||||||
|
stalls_with_os_events: 0,
|
||||||
out_ring: Vec::new(),
|
out_ring: Vec::new(),
|
||||||
out_idx: 0,
|
out_idx: 0,
|
||||||
video_conv: None,
|
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:
|
/// 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
|
/// 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
|
/// 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
|
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom).
|
||||||
/// session open the OS activates the virtual display → DWM composites it → a frame arrives within ~1 s,
|
/// A stash-capable driver republishes its retained desktop frame the moment it attaches (the
|
||||||
/// so this does not false-fail a normal (even idle) open; no frame within the window = genuinely broken.
|
/// 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<()> {
|
fn wait_for_attach(&self) -> Result<()> {
|
||||||
// Symmetric host-side binding sanity (proto v3 §3.2): OUR header must still name OUR
|
// 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
|
// 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);
|
let deadline = Instant::now() + Duration::from_secs(4);
|
||||||
// Compose-kick schedule: DWM only presents a display something DIRTIED, so on an idle
|
// First-frame expectation: a stash-capable driver republishes its retained desktop frame
|
||||||
// desktop a perfectly healthy attach sees no first frame (E_PENDING forever) and this gate
|
// the moment it attaches (`FrameStash`, frame_transport.rs), so on a healthy pairing the
|
||||||
// used to fail the session — the "idle desktop → no frames" gotcha (a real client escaped
|
// gate below clears in milliseconds even on a perfectly idle desktop. The compose-kick
|
||||||
// it only because its own input soon dirtied the desktop; a headless probe never did).
|
// schedule is the FALLBACK for pre-stash drivers / an empty stash (a display that has
|
||||||
// Give the natural post-activate compose a moment, then nudge.
|
// 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);
|
let mut next_kick = Instant::now() + Duration::from_millis(600);
|
||||||
loop {
|
loop {
|
||||||
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
|
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
|
||||||
@@ -1249,6 +1291,14 @@ impl IddPushCapturer {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if Instant::now() >= next_kick {
|
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);
|
kick_dwm_compose(self.target_id);
|
||||||
next_kick = Instant::now() + Duration::from_millis(800);
|
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
|
// 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
|
// 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
|
// never sees a frame and the 3 s recover-or-drop above kills a healthy session. A
|
||||||
// DWM (rate-limited) once the natural post-recreate compose has had its chance.
|
// 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)
|
if since.elapsed() > Duration::from_millis(600)
|
||||||
&& self.last_kick.elapsed() > Duration::from_millis(800)
|
&& self.last_kick.elapsed() > Duration::from_millis(800)
|
||||||
{
|
{
|
||||||
self.last_kick = Instant::now();
|
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);
|
kick_dwm_compose(self.target_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1646,24 +1703,70 @@ impl IddPushCapturer {
|
|||||||
// doesn't read as a DWM stall.
|
// doesn't read as a DWM stall.
|
||||||
self.stall_watch.reset();
|
self.stall_watch.reset();
|
||||||
} else if let Some(stall) = self.stall_watch.note_fresh(now) {
|
} 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;
|
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||||
// at debug level, and the web-console debug ring captures these.
|
// at debug level, and the web-console debug ring captures these.
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
gap_ms = stall.gap.as_millis() as u64,
|
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 \
|
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||||
delivered no frame for the gap; the present path stalled below capture"
|
delivered no frame for the gap; the present path stalled below capture"
|
||||||
);
|
);
|
||||||
if let Some(period) = stall.metronomic {
|
if let Some(period) = stall.metronomic {
|
||||||
tracing::warn!(
|
let suspects = crate::display_events::connected_inactive_externals();
|
||||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
let suspects = if suspects.is_empty() {
|
||||||
"capture stalls are METRONOMIC — DWM stops composing the virtual display \
|
"none".to_string()
|
||||||
on a stable period, i.e. a periodic display-path disturbance BELOW \
|
} else {
|
||||||
capture (DWM present clock / GPU driver / display-poller software). \
|
suspects.join(", ")
|
||||||
Correlate with 'slow display-descriptor poll'; if that never fires, the \
|
};
|
||||||
disturbance is outside punktfunk — try display topology=primary or \
|
let correlated = format!("{}/{}", self.stalls_with_os_events, self.stalls_seen);
|
||||||
extend (keep a physical output active), or a different refresh rate"
|
// 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
|
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
|
/// `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`).
|
/// the per-vendor default (AMF on, QSV off — see module docs and `encode/ffmpeg_win.rs`).
|
||||||
pub zerocopy: Option<bool>,
|
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,
|
pub ten_bit: bool,
|
||||||
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions).
|
/// `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
|
/// **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"
|
"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 —
|
// Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF —
|
||||||
// is the real switch; see the field doc).
|
// is the real switch; see the field doc).
|
||||||
four_four_four: val("PUNKTFUNK_444")
|
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 crate::capture::{CapturedFrame, PixelFormat};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
mod codec;
|
||||||
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
pub(crate) use codec::*;
|
||||||
/// 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 {
|
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,
|
/// 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
|
/// 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
|
/// Moonlight ([`crate::gamestream::serverinfo`]), in `quic::CODEC_*` bits. The GPU-less software
|
||||||
@@ -173,275 +91,6 @@ impl Codec {
|
|||||||
})();
|
})();
|
||||||
base | pyro
|
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
|
/// 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
|
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
|
// 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
|
// 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"))]
|
#[cfg(all(target_os = "linux", feature = "nvenc"))]
|
||||||
#[path = "encode/linux/nvenc_cuda.rs"]
|
#[path = "encode/linux/nvenc_cuda.rs"]
|
||||||
mod nvenc_cuda;
|
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 /
|
// 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
|
// 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.
|
// consumes CPU RGB `CapturedFrame`s and the statically-bundled openh264 build.
|
||||||
@@ -1420,41 +1153,6 @@ mod pyrowave;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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.
|
/// The probed-capability → wire-bitfield mapping the native codec advertisement is built from.
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1481,15 +1179,4 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(none.wire_mask(), None);
|
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 anyhow::{anyhow, bail, Context, Result};
|
||||||
use ffmpeg::format::Pixel;
|
use ffmpeg::format::Pixel;
|
||||||
use ffmpeg::util::frame::Video as VideoFrame;
|
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 ffmpeg_next as ffmpeg;
|
||||||
use std::os::raw::c_int;
|
use std::os::raw::c_int;
|
||||||
use std::ptr;
|
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
|
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 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
|
/// 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).
|
/// 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
|
/// 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).
|
/// needed (packed RGB/BGR have no padding byte; the NVENC `*0` formats do).
|
||||||
fn nvenc_input(format: PixelFormat) -> (Pixel, bool) {
|
fn nvenc_input(format: PixelFormat) -> (Pixel, bool) {
|
||||||
@@ -299,34 +289,8 @@ impl NvencEncoder {
|
|||||||
video.set_width(width);
|
video.set_width(width);
|
||||||
video.set_height(height);
|
video.set_height(height);
|
||||||
video.set_format(nvenc_pixel); // NVENC converts RGB→YUV internally
|
video.set_format(nvenc_pixel); // NVENC converts RGB→YUV internally
|
||||||
video.set_time_base(Rational(1, fps as i32));
|
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||||
video.set_frame_rate(Some(Rational(fps as i32, 1)));
|
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||||
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);
|
|
||||||
// Infinite GOP — NO periodic IDR. A keyframe at 5120x1440 is ~20-40x a P-frame, so a
|
// 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
|
// 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)
|
// "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>> {
|
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||||
let mut pkt = Packet::empty();
|
// Non-blocking single drain: a packet ships, EAGAIN (need another input frame) and EOF
|
||||||
match self.enc.receive_packet(&mut pkt) {
|
// (drained after flush) both mean "nothing this tick".
|
||||||
Ok(()) => {
|
match poll_encoder(&mut self.enc, self.fps)? {
|
||||||
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
|
PollOutcome::Packet(au) => Ok(Some(au)),
|
||||||
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
|
PollOutcome::Again | PollOutcome::Eof => Ok(None),
|
||||||
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"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![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 super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||||
use crate::capture::{CapturedFrame, FramePayload};
|
use crate::capture::{CapturedFrame, FramePayload};
|
||||||
use crate::zerocopy::cuda::{self, InputSurface};
|
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,
|
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,
|
/// 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/
|
/// or a driver older than our headers) — [`NvencCudaEncoder::open`] gates on it and the VAAPI/
|
||||||
/// software backends carry on.
|
/// software backends carry on.
|
||||||
@@ -129,7 +119,7 @@ fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
|
|||||||
.get_or_init(|| {
|
.get_or_init(|| {
|
||||||
let table = load_api();
|
let table = load_api();
|
||||||
if let Err(e) = &table {
|
if let Err(e) = &table {
|
||||||
tracing::warn!("NVENC (Linux direct) API unavailable: {e}");
|
tracing::warn!(error = %e, "NVENC (Linux direct) API unavailable");
|
||||||
}
|
}
|
||||||
table
|
table
|
||||||
})
|
})
|
||||||
@@ -220,21 +210,6 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
|||||||
/// helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
|
/// helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
|
||||||
const POOL: usize = 8;
|
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-
|
/// 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
|
/// 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.
|
/// 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: Option<cuda::CursorBlend>,
|
||||||
cursor_tried: bool,
|
cursor_tried: bool,
|
||||||
cursor_serial: u64,
|
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`
|
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
||||||
@@ -382,6 +365,9 @@ impl NvencCudaEncoder {
|
|||||||
cursor: None,
|
cursor: None,
|
||||||
cursor_tried: false,
|
cursor_tried: false,
|
||||||
cursor_serial: u64::MAX,
|
cursor_serial: u64::MAX,
|
||||||
|
cursor_upload_warned: false,
|
||||||
|
cursor_blend_warned: false,
|
||||||
|
diagnosed: false,
|
||||||
inited: false,
|
inited: false,
|
||||||
rfi_supported: false,
|
rfi_supported: false,
|
||||||
custom_vbv: false,
|
custom_vbv: false,
|
||||||
@@ -407,7 +393,16 @@ impl NvencCudaEncoder {
|
|||||||
for &bs in &self.bitstreams {
|
for &bs in &self.bitstreams {
|
||||||
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
|
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.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
|
||||||
self.bitstreams.clear();
|
self.bitstreams.clear();
|
||||||
self.pending.clear();
|
self.pending.clear();
|
||||||
@@ -446,11 +441,19 @@ impl NvencCudaEncoder {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut enc: *mut c_void = ptr::null_mut();
|
let mut enc: *mut c_void = ptr::null_mut();
|
||||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
|
||||||
.nv_ok()
|
// The NVENC docs require NvEncDestroyEncoder even after a FAILED open (the driver may
|
||||||
.map_err(|e| {
|
// have allocated the session slot before erroring) — without it, every failed open in
|
||||||
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
|
// 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 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 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);
|
let yuv444 = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE);
|
||||||
@@ -490,6 +493,62 @@ impl NvencCudaEncoder {
|
|||||||
Ok(())
|
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
|
/// 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
|
/// `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
|
/// shared by [`try_open_session`] and [`Encoder::reconfigure_bitrate`], so an in-place rate
|
||||||
@@ -512,164 +571,34 @@ impl NvencCudaEncoder {
|
|||||||
&mut preset,
|
&mut preset,
|
||||||
)
|
)
|
||||||
.nv_ok()
|
.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;
|
let mut cfg = preset.presetCfg;
|
||||||
|
|
||||||
// CBR, infinite GOP, P-only, ~1-frame VBV (mirror the Windows/Linux-libav RC config).
|
// Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
|
||||||
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
|
// low-latency contract. On Linux the full-chroma input is a YUV444 surface and the input is
|
||||||
cfg.frameIntervalP = 1;
|
// 8-bit today, so AV1's input-depth is 0.
|
||||||
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).
|
|
||||||
let yuv444_input = matches!(
|
let yuv444_input = matches!(
|
||||||
self.buffer_fmt,
|
self.buffer_fmt,
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
|
||||||
);
|
);
|
||||||
if self.chroma_444 && yuv444_input {
|
apply_low_latency_config(
|
||||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
&mut cfg,
|
||||||
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
LowLatencyConfig {
|
||||||
if self.bit_depth == 10 {
|
codec: self.codec,
|
||||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
|
bitrate,
|
||||||
}
|
fps: self.fps,
|
||||||
} else if self.bit_depth == 10 {
|
custom_vbv: self.custom_vbv,
|
||||||
match self.codec {
|
chroma_444: self.chroma_444,
|
||||||
Codec::H265 => {
|
full_chroma_input: yuv444_input,
|
||||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
|
bit_depth: self.bit_depth,
|
||||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
|
av1_input_depth_minus8: 0,
|
||||||
}
|
hdr: self.hdr,
|
||||||
Codec::Av1 => {
|
rfi_supported: self.rfi_supported,
|
||||||
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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(cfg)
|
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`.
|
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
|
||||||
/// Returns the session handle, or destroys it and returns the error.
|
/// 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> {
|
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
|
||||||
@@ -681,9 +610,14 @@ impl NvencCudaEncoder {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut enc: *mut c_void = ptr::null_mut();
|
let mut enc: *mut c_void = ptr::null_mut();
|
||||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
|
||||||
.nv_ok()
|
// Destroy-on-failed-open, as in `query_caps`: a failed open may still hold a session
|
||||||
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
|
// 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) {
|
let mut cfg = match self.build_config(enc, bitrate) {
|
||||||
Ok(cfg) => cfg,
|
Ok(cfg) => cfg,
|
||||||
@@ -692,13 +626,21 @@ impl NvencCudaEncoder {
|
|||||||
return Err(e);
|
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() {
|
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||||
Ok(()) => Ok(enc),
|
Ok(()) => Ok(enc),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = (api().destroy_encoder)(enc);
|
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)")?;
|
self.cu_ctx = cuda::context().context("shared CUDA context (Linux direct NVENC)")?;
|
||||||
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
|
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;
|
const FLOOR_BPS: u64 = 10_000_000;
|
||||||
let requested_bps = self.bitrate_bps;
|
let requested_bps = self.bitrate_bps;
|
||||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override
|
// 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)
|
(api().create_bitstream_buffer)(enc, &mut cb)
|
||||||
.nv_ok()
|
.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);
|
self.bitstreams.push(cb.bitstreamBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,7 +796,7 @@ impl NvencCudaEncoder {
|
|||||||
};
|
};
|
||||||
(api().register_resource)(self.encoder, &mut rr)
|
(api().register_resource)(self.encoder, &mut rr)
|
||||||
.nv_ok()
|
.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 {
|
self.ring.push(RingSlot {
|
||||||
surface,
|
surface,
|
||||||
reg: rr.registeredResource,
|
reg: rr.registeredResource,
|
||||||
@@ -858,14 +805,12 @@ impl NvencCudaEncoder {
|
|||||||
|
|
||||||
self.inited = true;
|
self.inited = true;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"NVENC CUDA session: {}x{}@{} {}-bit {} Mbps {:?} fmt={:?}",
|
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
||||||
self.width,
|
bit_depth = self.bit_depth,
|
||||||
self.height,
|
mbps = self.bitrate_bps / 1_000_000,
|
||||||
self.fps,
|
codec = ?self.codec_guid,
|
||||||
self.bit_depth,
|
fmt = ?self.buffer_fmt,
|
||||||
self.bitrate_bps / 1_000_000,
|
"NVENC CUDA session ready"
|
||||||
self.codec_guid,
|
|
||||||
self.buffer_fmt,
|
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -970,9 +915,19 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
if let Some(cb) = &self.cursor {
|
if let Some(cb) = &self.cursor {
|
||||||
if self.cursor_serial != ov.serial {
|
if self.cursor_serial != ov.serial {
|
||||||
match cb.upload(ov.rgba.as_slice(), ov.w, ov.h) {
|
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) => {
|
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),
|
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y),
|
||||||
};
|
};
|
||||||
if let Err(e) = r {
|
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)
|
(api().map_input_resource)(self.encoder, &mut mp)
|
||||||
.nv_ok()
|
.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;
|
let pts = self.frame_idx as u64;
|
||||||
self.frame_idx += 1;
|
self.frame_idx += 1;
|
||||||
@@ -1086,7 +1049,7 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
(api().encode_picture)(self.encoder, &mut pic)
|
(api().encode_picture)(self.encoder, &mut pic)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
|
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
|
||||||
self.pending.push_back((
|
self.pending.push_back((
|
||||||
self.bitstreams[slot],
|
self.bitstreams[slot],
|
||||||
mp.mappedResource,
|
mp.mappedResource,
|
||||||
@@ -1186,7 +1149,7 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
};
|
};
|
||||||
(api().lock_bitstream)(self.encoder, &mut lock)
|
(api().lock_bitstream)(self.encoder, &mut lock)
|
||||||
.nv_ok()
|
.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(
|
let data = std::slice::from_raw_parts(
|
||||||
lock.bitstreamBufferPtr as *const u8,
|
lock.bitstreamBufferPtr as *const u8,
|
||||||
lock.bitstreamSizeInBytes as usize,
|
lock.bitstreamSizeInBytes as usize,
|
||||||
@@ -1198,7 +1161,7 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
);
|
);
|
||||||
(api().unlock_bitstream)(self.encoder, bs)
|
(api().unlock_bitstream)(self.encoder, bs)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
|
.map_err(|e| nvenc_status::call_err("unlock_bitstream", e))?;
|
||||||
if !map.is_null() {
|
if !map.is_null() {
|
||||||
let _ = (api().unmap_input_resource)(self.encoder, map);
|
let _ = (api().unmap_input_resource)(self.encoder, map);
|
||||||
}
|
}
|
||||||
@@ -1242,7 +1205,15 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
};
|
};
|
||||||
let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
|
let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
|
||||||
version: nv::NV_ENC_RECONFIGURE_PARAMS_VER,
|
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()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// Keep the encoder's RC state and reference chain: no reset, no IDR — the in-flight
|
// 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"
|
"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,
|
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||||
};
|
};
|
||||||
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||||
if pw::pyrowave_encoder_create(&einfo, &mut enc) != pw::pyrowave_result_PYROWAVE_SUCCESS
|
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
|
||||||
{
|
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
|
||||||
tracing::error!("pyrowave: encoder rebuild failed");
|
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.pw_enc = enc;
|
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
|
// (plan §4.6 — wavelet quality collapses well above the AIMD floor); until then this
|
||||||
// faithfully applies whatever the caller asks.
|
// faithfully applies whatever the caller asks.
|
||||||
self.frame_budget = budget_for(bps, self.fps);
|
self.frame_budget = budget_for(bps, self.fps);
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
mbps = bps / 1_000_000,
|
mbps = bps / 1_000_000,
|
||||||
budget_kib = self.frame_budget / 1024,
|
budget_kib = self.frame_budget / 1024,
|
||||||
"pyrowave: per-frame rate budget retargeted in place"
|
"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 crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use ffmpeg::format::Pixel;
|
use ffmpeg::format::Pixel;
|
||||||
use ffmpeg::{codec, encoder, Dictionary, Packet, Rational};
|
use ffmpeg::{codec, encoder, Dictionary};
|
||||||
use ffmpeg_next as ffmpeg;
|
use ffmpeg_next as ffmpeg;
|
||||||
use std::ffi::{CStr, CString};
|
use std::ffi::{CStr, CString};
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
@@ -34,18 +34,11 @@ use std::os::raw::c_int;
|
|||||||
use std::ptr;
|
use std::ptr;
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
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
|
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`).
|
/// `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 {
|
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)
|
(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_width(width);
|
||||||
video.set_height(height);
|
video.set_height(height);
|
||||||
video.set_format(Pixel::NV12); // sw view; pix_fmt overridden to VAAPI below
|
video.set_format(Pixel::NV12); // sw view; pix_fmt overridden to VAAPI below
|
||||||
video.set_time_base(Rational(1, fps as i32));
|
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||||
video.set_frame_rate(Some(Rational(fps as i32, 1)));
|
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||||
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);
|
|
||||||
let raw = video.as_mut_ptr();
|
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)
|
(*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
|
// 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
|
// 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
|
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.
|
// 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));
|
.min(std::time::Duration::from_millis(12));
|
||||||
let deadline = std::time::Instant::now() + budget;
|
let deadline = std::time::Instant::now() + budget;
|
||||||
loop {
|
loop {
|
||||||
if let Some(au) = poll_encoder(enc, self.fps)? {
|
match poll_encoder(enc, self.fps)? {
|
||||||
self.in_flight = self.in_flight.saturating_sub(1);
|
PollOutcome::Packet(au) => {
|
||||||
return Ok(Some(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
|
// 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.
|
// 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
|
// 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.
|
// session's RC state IS the new rate from this frame on — later begins declare it.
|
||||||
self.bitrate = nb;
|
self.bitrate = nb;
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
mbps = nb / 1_000_000,
|
mbps = nb / 1_000_000,
|
||||||
"vulkan-encode: rate control retargeted in place (no IDR)"
|
"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();
|
let lib = load_factory();
|
||||||
if let Err(e) = &lib {
|
if let Err(e) = &lib {
|
||||||
// Once per process; only reachable when the backend resolved to AMF on this box.
|
// 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
|
lib
|
||||||
})
|
})
|
||||||
@@ -1101,7 +1101,8 @@ unsafe fn set_prop(
|
|||||||
} else {
|
} else {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
property = %name,
|
property = %name,
|
||||||
result = %format!("{} ({r})", result_name(r)),
|
result = result_name(r),
|
||||||
|
amf_code = r,
|
||||||
"optional AMF encoder property rejected (VCN generation/driver) — continuing"
|
"optional AMF encoder property rejected (VCN generation/driver) — continuing"
|
||||||
);
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
@@ -1666,15 +1667,18 @@ impl AmfEncoder {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
codec = ?self.codec,
|
codec = ?self.codec,
|
||||||
context = context_no,
|
context = context_no,
|
||||||
device = format!("{:#x}", device.as_raw() as usize),
|
device = %format_args!("{:#x}", device.as_raw() as usize),
|
||||||
"native AMF encode active (context #{context_no}, {}x{}@{}, zero-copy D3D11 {} ring, runtime {}.{}.{})",
|
width = self.width,
|
||||||
self.width,
|
height = self.height,
|
||||||
self.height,
|
fps = self.fps,
|
||||||
self.fps,
|
ring = if self.ten_bit { "P010" } else { "NV12" },
|
||||||
if self.ten_bit { "P010" } else { "NV12" },
|
runtime = %format_args!(
|
||||||
(lib.version >> 48) & 0xffff,
|
"{}.{}.{}",
|
||||||
(lib.version >> 32) & 0xffff,
|
(lib.version >> 48) & 0xffff,
|
||||||
(lib.version >> 16) & 0xffff,
|
(lib.version >> 32) & 0xffff,
|
||||||
|
(lib.version >> 16) & 0xffff
|
||||||
|
),
|
||||||
|
"native AMF encode active (zero-copy D3D11)"
|
||||||
);
|
);
|
||||||
self.inner = Some(Inner {
|
self.inner = Some(Inner {
|
||||||
comp,
|
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
|
/// [`probe_can_encode`] against an explicit device (separated so the live tests can pin the AMD
|
||||||
/// adapter on a hybrid box).
|
/// adapter on a hybrid box).
|
||||||
fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
|
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() {
|
if try_factory().is_err() {
|
||||||
return false;
|
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
|
// 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
|
// 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
|
// 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 {
|
unsafe {
|
||||||
let Ok(lib) = try_factory() else { return false };
|
let Ok(lib) = try_factory() else { return false };
|
||||||
let mut ctx: *mut sys::AmfContext = ptr::null_mut();
|
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;
|
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 {
|
if r != sys::AMF_OK {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
result = %format!("{} ({r})", result_name(r)),
|
result = result_name(r),
|
||||||
|
amf_code = r,
|
||||||
"AMF forced-keyframe picture type rejected"
|
"AMF forced-keyframe picture type rejected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2192,7 +2247,8 @@ impl Encoder for AmfEncoder {
|
|||||||
if r != sys::AMF_OK {
|
if r != sys::AMF_OK {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
slot,
|
slot,
|
||||||
result = %format!("{} ({r})", result_name(r)),
|
result = result_name(r),
|
||||||
|
amf_code = r,
|
||||||
"AMF LTR mark rejected"
|
"AMF LTR mark rejected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2268,8 @@ impl Encoder for AmfEncoder {
|
|||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
slot,
|
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"
|
"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).
|
// end-of-stream (remaining AUs then surface through `poll` until AMF_EOF).
|
||||||
let r = unsafe { ((*(*inner.comp.0).vtbl).drain)(inner.comp.0) };
|
let r = unsafe { ((*(*inner.comp.0).vtbl).drain)(inner.comp.0) };
|
||||||
if r != sys::AMF_OK {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2690,7 +2751,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Drive `enc` at the real frame cadence and return each frame's **submit→AU** wall-clock
|
/// 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
|
/// 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
|
/// 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),
|
/// 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 crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use ffmpeg::format::Pixel;
|
use ffmpeg::format::Pixel;
|
||||||
use ffmpeg::{codec, encoder, Dictionary, Packet, Rational};
|
use ffmpeg::{codec, encoder, Dictionary};
|
||||||
use ffmpeg_next as ffmpeg;
|
use ffmpeg_next as ffmpeg;
|
||||||
use std::os::raw::{c_int, c_uint, c_void};
|
use std::os::raw::{c_int, c_uint, c_void};
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
@@ -60,14 +60,12 @@ use windows::Win32::Graphics::Dxgi::Common::{
|
|||||||
DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_SAMPLE_DESC,
|
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
|
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
|
/// `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
|
/// 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
|
/// 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)
|
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,
|
/// 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
|
/// 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.
|
/// 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
|
// 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`.
|
// D3D11/QSV below; libavcodec still uses this as `sw_pix_fmt`.
|
||||||
video.set_format(Pixel::from(sw_pix_fmt));
|
video.set_format(Pixel::from(sw_pix_fmt));
|
||||||
video.set_time_base(Rational(1, fps as i32));
|
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||||
video.set_frame_rate(Some(Rational(fps as i32, 1)));
|
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||||
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);
|
|
||||||
let raw = video.as_mut_ptr();
|
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)
|
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||||
if ten_bit {
|
if ten_bit {
|
||||||
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
|
// 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
|
/// `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.)
|
/// Welcome. (Follow-up: implement + validate on an RDNA3+/Arc Windows box.)
|
||||||
pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
|
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
|
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`).
|
/// The immediate context of an `ID3D11Device` (for `CopyResource`/`CopySubresourceRegion`).
|
||||||
unsafe fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
|
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
|
// 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.
|
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![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 super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
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,
|
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
|
/// 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
|
/// 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
|
/// ([`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 {
|
if let Err(e) = &table {
|
||||||
// Once per process. Only reachable when something resolved to NVENC on this box
|
// 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.
|
// (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
|
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).
|
// GPU-saturating game; this must be ≥ the helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
|
||||||
const POOL: usize = 8;
|
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
|
/// 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
|
/// 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
|
/// (`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>,
|
work_rx: mpsc::Receiver<RetrieveJob>,
|
||||||
done_tx: mpsc::Sender<RetrieveDone>,
|
done_tx: mpsc::Sender<RetrieveDone>,
|
||||||
) {
|
) {
|
||||||
crate::punktfunk1::boost_thread_priority(false);
|
crate::native::boost_thread_priority(false);
|
||||||
while let Ok(job) = work_rx.recv() {
|
while let Ok(job) = work_rx.recv() {
|
||||||
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
|
// 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
|
// 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);
|
let _ = (api().unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void);
|
||||||
Ok((data, keyframe))
|
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 {
|
for &bs in &self.bitstreams {
|
||||||
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
|
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).
|
// 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);
|
LIVE_SESSION_UNITS.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||||
self.session_units = 0;
|
self.session_units = 0;
|
||||||
@@ -633,11 +620,19 @@ impl NvencD3d11Encoder {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut enc: *mut c_void = ptr::null_mut();
|
let mut enc: *mut c_void = ptr::null_mut();
|
||||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
|
||||||
.nv_ok()
|
// The NVENC docs require NvEncDestroyEncoder even after a FAILED open (the driver may
|
||||||
.map_err(|e| {
|
// have allocated the session slot before erroring) — without it, every failed open in
|
||||||
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
|
// 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 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 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);
|
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,
|
&mut preset,
|
||||||
)
|
)
|
||||||
.nv_ok()
|
.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;
|
let mut cfg = preset.presetCfg;
|
||||||
|
|
||||||
// Mirror the Linux RC config: CBR, infinite GOP, P-only, ~1-frame VBV.
|
// Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
|
||||||
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
|
// low-latency contract. On Windows the full-chroma input is a packed-RGB surface (NVENC
|
||||||
cfg.frameIntervalP = 1;
|
// CSCs it internally under FREXT), and AV1's input-depth follows the surface format — 10-bit
|
||||||
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
|
// for an ABGR10 / YUV420_10BIT input, else 8-bit.
|
||||||
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.
|
|
||||||
let rgb_input = matches!(
|
let rgb_input = matches!(
|
||||||
self.buffer_fmt,
|
self.buffer_fmt,
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
|
||||||
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||||
);
|
);
|
||||||
if self.chroma_444 && rgb_input {
|
let ten_bit_in = matches!(
|
||||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
self.buffer_fmt,
|
||||||
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||||
if self.bit_depth == 10 {
|
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV420_10BIT
|
||||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // Main 4:4:4 10
|
);
|
||||||
}
|
apply_low_latency_config(
|
||||||
} else if self.bit_depth == 10 {
|
&mut cfg,
|
||||||
// 10-bit (HDR foundation): NVENC upconverts an 8-bit input; 8-bit leaves the preset
|
LowLatencyConfig {
|
||||||
// default profile untouched. PER CODEC — stamping the HEVC Main10 GUID + hevcConfig
|
codec: self.codec,
|
||||||
// bitfields onto an AV1 session was an unconditional INVALID_PARAM (the "AV1 10-bit
|
bitrate,
|
||||||
// session never opens" field bug); AV1's Main profile already covers 10-bit, it only
|
fps: self.fps,
|
||||||
// needs the output depth set on its own config.
|
custom_vbv: self.custom_vbv,
|
||||||
match self.codec {
|
chroma_444: self.chroma_444,
|
||||||
Codec::H265 => {
|
full_chroma_input: rgb_input,
|
||||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
|
bit_depth: self.bit_depth,
|
||||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
|
av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 },
|
||||||
// 10 - 8
|
hdr: self.hdr,
|
||||||
}
|
rfi_supported: self.rfi_supported,
|
||||||
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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(cfg)
|
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
|
/// 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
|
/// 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.
|
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
|
||||||
@@ -946,9 +760,14 @@ impl NvencD3d11Encoder {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut enc: *mut c_void = ptr::null_mut();
|
let mut enc: *mut c_void = ptr::null_mut();
|
||||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
|
||||||
.nv_ok()
|
// Destroy-on-failed-open, as in `query_caps`: a failed open may still hold a session
|
||||||
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
|
// 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) {
|
let mut cfg = match self.build_config(enc, bitrate) {
|
||||||
Ok(cfg) => cfg,
|
Ok(cfg) => cfg,
|
||||||
@@ -957,13 +776,21 @@ impl NvencD3d11Encoder {
|
|||||||
return Err(e);
|
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() {
|
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||||
Ok(()) => Ok(enc),
|
Ok(()) => Ok(enc),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = (api().destroy_encoder)(enc);
|
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,
|
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
split_mode,
|
split_mode,
|
||||||
bit_depth = self.bit_depth,
|
bit_depth = self.bit_depth,
|
||||||
pixel_rate,
|
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
|
// 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
|
// `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).
|
// mode (a split session occupies one hardware session per engine).
|
||||||
self.session_units = split_mode_units(split_mode);
|
self.session_units = split_mode_units(split_mode);
|
||||||
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||||
if self.bitrate_bps < requested_bps {
|
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
|
||||||
tracing::info!(
|
// info line for the same event here.)
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. one output bitstream per in-flight slot. There is NO encoder-owned input pool: the
|
// 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.
|
// 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)
|
(api().create_bitstream_buffer)(enc, &mut cb)
|
||||||
.nv_ok()
|
.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);
|
self.bitstreams.push(cb.bitstreamBuffer);
|
||||||
}
|
}
|
||||||
// Async retrieve: one auto-reset completion event per pool bitstream, registered with
|
// 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)
|
(api().register_async_event)(enc, &mut ep)
|
||||||
.nv_ok()
|
.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);
|
self.events.push(ev.0 as usize);
|
||||||
}
|
}
|
||||||
let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
|
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
|
// 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
|
// 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
|
// 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.
|
// the stream really carries instead of silently claiming full chroma.
|
||||||
if self.chroma_444
|
if self.chroma_444
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
@@ -1372,7 +1194,7 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
};
|
};
|
||||||
(api().register_resource)(self.encoder, &mut rr)
|
(api().register_resource)(self.encoder, &mut rr)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("register_resource: {e:?}"))?;
|
.map_err(|e| nvenc_status::call_err("register_resource", e))?;
|
||||||
self.regs
|
self.regs
|
||||||
.insert(key, (rr.registeredResource, frame.texture.clone()));
|
.insert(key, (rr.registeredResource, frame.texture.clone()));
|
||||||
}
|
}
|
||||||
@@ -1385,7 +1207,7 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
};
|
};
|
||||||
(api().map_input_resource)(self.encoder, &mut mp)
|
(api().map_input_resource)(self.encoder, &mut mp)
|
||||||
.nv_ok()
|
.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;
|
let pts = self.frame_idx as u64;
|
||||||
self.frame_idx += 1;
|
self.frame_idx += 1;
|
||||||
@@ -1469,7 +1291,7 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
(api().encode_picture)(self.encoder, &mut pic)
|
(api().encode_picture)(self.encoder, &mut pic)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
|
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
|
||||||
self.pending.push_back((
|
self.pending.push_back((
|
||||||
self.bitstreams[slot],
|
self.bitstreams[slot],
|
||||||
mp.mappedResource,
|
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.
|
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
||||||
EncoderCaps {
|
EncoderCaps {
|
||||||
supports_rfi: self.rfi_supported,
|
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
|
// 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.
|
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
|
||||||
chroma_444: self.chroma_444,
|
chroma_444: self.chroma_444,
|
||||||
@@ -1629,7 +1455,7 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
};
|
};
|
||||||
(api().lock_bitstream)(self.encoder, &mut lock)
|
(api().lock_bitstream)(self.encoder, &mut lock)
|
||||||
.nv_ok()
|
.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(
|
let data = std::slice::from_raw_parts(
|
||||||
lock.bitstreamBufferPtr as *const u8,
|
lock.bitstreamBufferPtr as *const u8,
|
||||||
lock.bitstreamSizeInBytes as usize,
|
lock.bitstreamSizeInBytes as usize,
|
||||||
@@ -1641,7 +1467,7 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
);
|
);
|
||||||
(api().unlock_bitstream)(self.encoder, bs)
|
(api().unlock_bitstream)(self.encoder, bs)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
|
.map_err(|e| nvenc_status::call_err("unlock_bitstream", e))?;
|
||||||
if !map.is_null() {
|
if !map.is_null() {
|
||||||
let _ = (api().unmap_input_resource)(self.encoder, map);
|
let _ = (api().unmap_input_resource)(self.encoder, map);
|
||||||
}
|
}
|
||||||
@@ -1694,7 +1520,11 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
};
|
};
|
||||||
let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
|
let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
|
||||||
version: nv::NV_ENC_RECONFIGURE_PARAMS_VER,
|
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,
|
&mut cfg,
|
||||||
self.split_mode,
|
self.split_mode,
|
||||||
self.session_async,
|
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`).
|
/// 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;
|
/// HEVC-only; the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before*
|
||||||
/// the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before* the Welcome
|
/// the Welcome so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a
|
||||||
/// so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a card without it).
|
/// card without it). See [`probe_encode_cap`] for the throwaway-session mechanics.
|
||||||
pub fn probe_can_encode_444(codec: Codec) -> bool {
|
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::Foundation::HMODULE;
|
||||||
use windows::Win32::Graphics::Direct3D::{
|
use windows::Win32::Graphics::Direct3D::{
|
||||||
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
|
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,
|
D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
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".
|
// 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.
|
// This is also the `api()` gate for every NVENC call below.
|
||||||
if try_api().is_err() {
|
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).
|
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
|
||||||
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
|
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
|
||||||
// fills `device` or returns Err (→ false). `open_encode_session_ex` opens an NVENC session
|
// 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
|
// against that device's raw pointer (valid while `device` is held) or errors (→ false, after
|
||||||
// nothing down). `get_encode_caps` reads one scalar cap into `val` via the loaded API table.
|
// 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
|
// `destroy_encoder` frees the session exactly once; `device`/its context drop with the COM
|
||||||
// wrappers. No handle escapes this call and nothing runs concurrently.
|
// wrappers. No handle escapes this call and nothing runs concurrently.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1816,15 +1668,19 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
|||||||
.nv_ok()
|
.nv_ok()
|
||||||
.is_err()
|
.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;
|
return false;
|
||||||
}
|
}
|
||||||
let mut param = nv::NV_ENC_CAPS_PARAM {
|
let mut param = nv::NV_ENC_CAPS_PARAM {
|
||||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||||
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE,
|
capsToQuery: cap,
|
||||||
reserved: [0; 62],
|
reserved: [0; 62],
|
||||||
};
|
};
|
||||||
let mut val: i32 = 0;
|
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()
|
.nv_ok()
|
||||||
.is_ok()
|
.is_ok()
|
||||||
&& val != 0;
|
&& 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);
|
punktfunk_core::transport::grow_socket_buffers(&sock);
|
||||||
// The client pings the audio port (~every 500ms) so we learn where to send.
|
// The client pings the audio port (~every 500ms) so we learn where to send.
|
||||||
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
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 mut probe = [0u8; 256];
|
||||||
let (_, client) = sock
|
let (_, client) = sock
|
||||||
.recv_from(&mut probe)
|
.recv_from(&mut probe)
|
||||||
@@ -275,7 +275,7 @@ fn run(
|
|||||||
&sock,
|
&sock,
|
||||||
punktfunk_core::transport::MediaClass::Audio,
|
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
|
// 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.
|
// 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);
|
timestamp = timestamp.wrapping_add(frame_ms as u32);
|
||||||
sent += 1;
|
sent += 1;
|
||||||
if sent % 400 == 0 {
|
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).
|
// 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,
|
Ok(None) => break,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %format!("{e:?}"), "control: service error");
|
tracing::warn!(error = ?e, "control: service error");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
|||||||
for wire in out {
|
for wire in out {
|
||||||
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
|
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 inner == 0x0301 {
|
||||||
if let Some((first, last)) = decode_rfi_range(&pt) {
|
if let Some((first, last)) = decode_rfi_range(&pt) {
|
||||||
*state.rfi_range.lock().unwrap() = Some((first, last));
|
*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 {
|
} else {
|
||||||
state
|
state
|
||||||
.force_idr
|
.force_idr
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
tracing::info!("control: RFI request (no range) → keyframe");
|
tracing::debug!("control: RFI request (no range) → keyframe");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -227,8 +227,8 @@ fn on_receive(
|
|||||||
state
|
state
|
||||||
.force_idr
|
.force_idr
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
ty = format!("{inner:#06x}"),
|
ty = %format_args!("{inner:#06x}"),
|
||||||
"control: IDR request → keyframe"
|
"control: IDR request → keyframe"
|
||||||
);
|
);
|
||||||
return;
|
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
|
// 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,
|
// 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;
|
// 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;
|
use punktfunk_core::input::gamepad as wire;
|
||||||
pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP;
|
pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP;
|
||||||
pub const BTN_DPAD_DOWN: u32 = wire::BTN_DPAD_DOWN;
|
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