feat(codec): make client codec selection real — GPU-aware native advertisement
apple / swift (push) Successful in 1m7s
release / apple (push) Successful in 8m27s
windows-host / package (push) Successful in 7m59s
apple / screenshots (push) Failing after 2m38s
android / android (push) Successful in 4m44s
ci / rust (push) Failing after 46s
arch / build-publish (push) Successful in 5m32s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m2s
deb / build-publish (push) Successful in 4m44s
ci / bench (push) Successful in 5m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
decky / build-publish (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m14s
docker / deploy-docs (push) Successful in 13s

The clients' codec pickers sent a preference the host threw away:
host_wire_caps() hardcoded HEVC for every GPU backend, so the native
path never emitted H.264/AV1 regardless of what the user chose.

Host: advertise what the backend actually encodes, mirroring the
GameStream serverinfo logic — software openh264 emits H.264; probed
backends (Linux VAAPI, Windows AMF/QSV) advertise their per-GPU
CodecSupport via the new wire_mask(); NVENC keeps the
Moonlight-validated H.264|HEVC|AV1 static superset; an empty probe
falls back to the superset so auto clients still resolve HEVC. Gate
10-bit to HEVC (like the 4:4:4 gate) now that a client can steer the
codec — Main10 is the only 10-bit encode path. Fix the web-console
stats label hardcoded to "hevc" (new Codec::label(), shared with the
GameStream register_session mapping).

Android: replace the hardcoded H264|HEVC Hello advertisement with a
videoCodecs param fed by VideoDecoders.decodableCodecBits() (AV1 bit
only when a real hardware video/av01 decoder exists — the decode loop
was already mime-driven); offer AV1 in the codec picker on capable
devices.

Decky: add the missing "Video codec" dropdown (auto/hevc/h264/av1) to
the plugin settings, round-tripping the same codec key the flatpak
client reads.

Apple: unchanged by design (AnnexB.swift is NAL-only, AV1 is never
advertised); refresh the stale "hosts don't emit AV1 on the native
path yet" comments here and host-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 16:45:30 +02:00
parent 0290bf7285
commit 838a1239cf
15 changed files with 225 additions and 41 deletions
@@ -3,6 +3,7 @@ package io.unom.punktfunk
import android.content.Context import android.content.Context
import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.kit.security.ClientIdentity import io.unom.punktfunk.kit.security.ClientIdentity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -41,7 +42,10 @@ suspend fun connectToHost(
host, port, w, h, hz, host, port, w, h, hz,
identity.certPem, identity.privateKeyPem, pinHex, identity.certPem, identity.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref, settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs, hdrEnabled, settings.audioChannels,
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
// the user's soft codec preference — the host resolves the emitted codec from both.
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
launch, launch,
) )
} }
@@ -26,8 +26,9 @@ data class Settings(
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it /** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */ * can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2, val audioChannels: Int = 2,
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference — the /** Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */ * preference — the host emits it when it can, else falls back. AMediaCodec decodes whichever
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
val codec: String = "auto", val codec: String = "auto",
val micEnabled: Boolean = false, val micEnabled: Boolean = false,
/** /**
@@ -271,14 +272,17 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
8 to "7.1 Surround", 8 to "7.1 Surround",
) )
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. */ /** (stored value, label) for the preferred video codec. `"auto"` = host decides. The `"av1"` row
* only makes sense on a device with a real AV1 decoder — SettingsScreen filters it out otherwise. */
val CODEC_OPTIONS = listOf( val CODEC_OPTIONS = listOf(
"auto" to "Automatic", "auto" to "Automatic",
"hevc" to "HEVC (H.265)", "hevc" to "HEVC (H.265)",
"h264" to "H.264 (AVC)", "h264" to "H.264 (AVC)",
"av1" to "AV1",
) )
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2. */ /** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
* AV1=4. */
fun Settings.preferredCodec(): Int = when (codec) { fun Settings.preferredCodec(): Int = when (codec) {
"h264" -> 1 "h264" -> 1
"hevc" -> 2 "hevc" -> 2
@@ -65,6 +65,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.unom.punktfunk.kit.VideoDecoders
/** /**
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category * Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
@@ -300,7 +301,12 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
update(s.copy(bitrateKbps = kbps)) update(s.copy(bitrateKbps = kbps))
} }
SettingDropdown(label = "Video codec", options = CODEC_OPTIONS, selected = s.codec) { c -> // AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
// device stays visible so the selection is always representable.
val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null }
val codecOptions = CODEC_OPTIONS.filter { (v, _) -> v != "av1" || av1Capable || s.codec == "av1" }
SettingDropdown(label = "Video codec", options = codecOptions, selected = s.codec) { c ->
update(s.copy(codec = c)) update(s.copy(codec = c))
} }
@@ -48,6 +48,9 @@ object NativeBridge {
gamepadPref: Int, gamepadPref: Int,
hdrEnabled: Boolean, hdrEnabled: Boolean,
audioChannels: Int, audioChannels: Int,
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this ∩ its GPU. */
videoCodecs: Int,
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */ /** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
preferredCodec: Int, preferredCodec: Int,
timeoutMs: Int, timeoutMs: Int,
@@ -44,6 +44,15 @@ object VideoDecoders {
* Pick the best decoder for [mime] (`"video/hevc"` / `"video/avc"` / `"video/av01"`), or `null` * Pick the best decoder for [mime] (`"video/hevc"` / `"video/avc"` / `"video/av01"`), or `null`
* to let the platform resolve its default. Enumerates once — call at stream start. * to let the platform resolve its default. Enumerates once — call at stream start.
*/ */
/**
* The `quic::CODEC_*` bitfield of codecs this device can decode, advertised in the Hello so the
* host never emits a codec the decode loop can't open: H.264 (1) and HEVC (2) always (universal
* on Android hardware), plus AV1 (4) only when [pickDecoder] finds a real (hardware, non-blocked)
* `video/av01` decoder. Enumerates `MediaCodecList` — call at connect time, not per frame.
*/
fun decodableCodecBits(): Int =
1 or 2 or (if (pickDecoder("video/av01") != null) 4 else 0)
fun pickDecoder(mime: String): DecoderChoice? { fun pickDecoder(mime: String): DecoderChoice? {
if (mime.isEmpty()) return null if (mime.isEmpty()) return null
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos } val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
+18 -4
View File
@@ -76,6 +76,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
gamepad_pref: jint, gamepad_pref: jint,
hdr_enabled: jboolean, hdr_enabled: jboolean,
audio_channels: jint, audio_channels: jint,
video_codecs: jint,
preferred_codec: jint, preferred_codec: jint,
timeout_ms: jint, timeout_ms: jint,
launch: JString<'local>, launch: JString<'local>,
@@ -142,10 +143,23 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else // decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
// normalizes to stereo here. // normalizes to stereo here.
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8), punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
// Codecs this device can decode — AMediaCodec decodes both HEVC and H.264 (AV1 isn't wired; // Codecs this device can decode, ranked on the Kotlin side (`VideoDecoders.decodableCodecBits`:
// hosts don't emit it on the native path yet). The host resolves the emitted codec from these // H.264 + HEVC always, AV1 when a real `video/av01` decoder exists — AMediaCodec is
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below. // mime-driven, see `codec_mime`). Mask to the known bits and fall back to the pre-AV1
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC, // H.264|HEVC pair on 0 so a bogus value can't advertise nothing and kill the handshake.
// The host resolves the emitted codec from these + the soft `preferred_codec` and echoes it
// in `connector.codec`, which drives the mime below.
{
let bits = (video_codecs.clamp(0, u8::MAX as jint) as u8)
& (punktfunk_core::quic::CODEC_H264
| punktfunk_core::quic::CODEC_HEVC
| punktfunk_core::quic::CODEC_AV1);
if bits == 0 {
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC
} else {
bits
}
},
preferred_codec.clamp(0, u8::MAX as jint) as u8, preferred_codec.clamp(0, u8::MAX as jint) as u8,
launch, // a store-qualified library id to boot into a game, or None for the desktop launch, // a store-qualified library id to boot into a game, or None for the desktop
pin, // Some → Crypto on host-fp mismatch pin, // Some → Crypto on host-fp mismatch
@@ -194,9 +194,10 @@ final class SessionModel: ObservableObject {
if want444, canDecode444 { if want444, canDecode444 {
videoCaps |= PunktfunkConnection.videoCap444 videoCaps |= PunktfunkConnection.videoCap444
} }
// This client's VideoToolbox path decodes H.264 and HEVC (AV1 isn't wired hosts don't // This client's VideoToolbox path decodes H.264 and HEVC (AV1 depacketization isn't
// emit it on the native path yet). The host resolves the emitted codec from these + the // wired AnnexB.swift is NAL-only so it must never be advertised here). The host
// soft `preferredCodec`; `resolvedCodec` reflects what it chose. // resolves the emitted codec from these + the soft `preferredCodec`; `resolvedCodec`
// reflects what it chose.
let videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC let videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
let result = Result { try PunktfunkConnection( let result = Result { try PunktfunkConnection(
host: host.address, port: host.port, host: host.address, port: host.port,
@@ -38,8 +38,8 @@ enum SettingsOptions {
HUDPlacement.allCases.map { ($0.label, $0.rawValue) } HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Video-codec preference (`DefaultsKey.codec`) a soft preference the host falls back from. /// Video-codec preference (`DefaultsKey.codec`) a soft preference the host falls back from.
/// No AV1: this client's VideoToolbox path decodes H.264/HEVC only (hosts don't emit AV1 on /// No AV1: this client's VideoToolbox path decodes H.264/HEVC only (AnnexB.swift is NAL-only),
/// the native path yet). /// so it never advertises AV1 offering it here would be a dead setting.
static let codecs: [(label: String, tag: String)] = [ static let codecs: [(label: String, tag: String)] = [
("Automatic", "auto"), ("Automatic", "auto"),
("HEVC (H.265)", "hevc"), ("HEVC (H.265)", "hevc"),
@@ -6,8 +6,8 @@
// buffers whose NALs are 4-byte-length-prefixed. This file converts between the two, for // buffers whose NALs are 4-byte-length-prefixed. This file converts between the two, for
// the codec the host resolved in the Welcome (`connection.videoCodec`) HEVC and H.264 // the codec the host resolved in the Welcome (`connection.videoCodec`) HEVC and H.264
// differ only in NAL-header layout and which parameter sets exist (HEVC adds a VPS). AV1 // differ only in NAL-header layout and which parameter sets exist (HEVC adds a VPS). AV1
// is not an Annex-B/NAL codec and isn't handled here (hosts don't emit it on the native // is not an Annex-B/NAL codec and isn't handled here this client never advertises it in
// path yet). // the Hello, so a host never emits it at us.
// //
// HOT PATH: both pumps run `formatDescription(fromIDR:codec:)` + `sampleBuffer(au:format:codec:)` // HOT PATH: both pumps run `formatDescription(fromIDR:codec:)` + `sampleBuffer(au:format:codec:)`
// once per AU, so the conversion is built on `forEachNAL` a zero-copy scan over the AU's bytes // once per AU, so the conversion is built on `forEachNAL` a zero-copy scan over the AU's bytes
+1 -1
View File
@@ -669,7 +669,7 @@ class Plugin:
# The client's own defaults (native display, host-default bitrate, auto pad). # The client's own defaults (native display, host-default bitrate, auto pad).
return { return {
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0, "width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
"gamepad": "auto", "compositor": "auto", "codec": "auto", "gamepad": "auto", "compositor": "auto",
"inhibit_shortcuts": True, "mic_enabled": False, "inhibit_shortcuts": True, "mic_enabled": False,
} }
+2 -1
View File
@@ -61,13 +61,14 @@ export interface RunnerInfo {
} }
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more // The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
// keys (codec, decoder, … set from the desktop client's own UI) — they round-trip untouched // keys (decoder, … set from the desktop client's own UI) — they round-trip untouched
// because get_settings returns the whole parsed file and patches are object spreads. // because get_settings returns the whole parsed file and patches are object spreads.
export interface StreamSettings { export interface StreamSettings {
width: number; // 0 = native width: number; // 0 = native
height: number; // 0 = native height: number; // 0 = native
refresh_hz: number; // 0 = native refresh_hz: number; // 0 = native
bitrate_kbps: number; // 0 = host default bitrate_kbps: number; // 0 = host default
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck" gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
inhibit_shortcuts: boolean; inhibit_shortcuts: boolean;
+24
View File
@@ -34,6 +34,15 @@ const GAMEPAD_LABELS: Record<string, string> = {
dualshock4: "DualShock 4", dualshock4: "DualShock 4",
steamdeck: "Steam Deck", steamdeck: "Steam Deck",
}; };
// Mirrors the desktop client's picker (ui_settings.rs CODECS) — a soft preference the host
// falls back from when its GPU can't encode it.
const CODECS = ["auto", "hevc", "h264", "av1"];
const CODEC_LABELS: Record<string, string> = {
auto: "Automatic",
hevc: "HEVC (H.265)",
h264: "H.264 (AVC)",
av1: "AV1",
};
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"]; const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
const COMPOSITOR_LABELS: Record<string, string> = { const COMPOSITOR_LABELS: Record<string, string> = {
auto: "Automatic", auto: "Automatic",
@@ -108,6 +117,21 @@ export const SettingsSection: FC = () => {
valueSuffix=" Mbit/s" valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })} onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/> />
<Field
label="Video codec"
description="Preferred stream codec — the host falls back when its GPU can't encode it"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={CODECS.map((c) => ({ data: c, label: CODEC_LABELS[c] ?? c }))}
selectedOption={s.codec ?? "auto"}
onChange={(o) => patch({ codec: o.data as string })}
/>
</div>
</RowActions>
</Field>
<Field <Field
label="Gamepad type" label="Gamepad type"
description="Which virtual controller the host creates for your inputs" description="Which virtual controller the host creates for your inputs"
+119 -6
View File
@@ -77,14 +77,68 @@ impl Codec {
} }
/// 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 GPU-less software encoder (openh264) produces H.264 /// given the resolved encode backend — the same GPU-aware advertisement GameStream builds for
/// only; every GPU backend emits HEVC today (per-GPU H.264/AV1 negotiation on the native path is /// Moonlight ([`crate::gamestream::serverinfo`]), in `quic::CODEC_*` bits. The GPU-less software
/// future work — GameStream already negotiates codecs with Moonlight separately). Fed to /// encoder (openh264) produces H.264 only; the probed backends (Linux VAAPI, Windows AMF/QSV)
/// advertise exactly what the GPU encodes ([`vaapi_codec_support`] / [`windows_codec_support`] —
/// AV1 encode is narrow, an old iGPU might lack HEVC); NVENC keeps the Moonlight-validated
/// static superset. An empty probe means the GPU wasn't usable at probe time (GPU-less CI,
/// wrong-vendor pref), not that it encodes nothing — fall back to the superset so `resolve_codec`
/// still lands on HEVC for an auto client, exactly the pre-probe behaviour. Fed to
/// [`punktfunk_core::quic::resolve_codec`] against the client's advertised codecs. /// [`punktfunk_core::quic::resolve_codec`] against the client's advertised codecs.
pub fn host_wire_caps() -> u8 { pub fn host_wire_caps() -> u8 {
match crate::config::config().encoder_pref.as_str() { /// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream
"software" | "sw" | "openh264" => punktfunk_core::quic::CODEC_H264, /// `SERVER_CODEC_MODE_SUPPORT` advertisement for the unprobed backends.
_ => punktfunk_core::quic::CODEC_HEVC, const GPU_SUPERSET: u8 = punktfunk_core::quic::CODEC_H264
| punktfunk_core::quic::CODEC_HEVC
| punktfunk_core::quic::CODEC_AV1;
#[cfg(target_os = "linux")]
{
if matches!(
crate::config::config().encoder_pref.as_str(),
"software" | "sw" | "openh264"
) {
return punktfunk_core::quic::CODEC_H264;
}
if linux_zero_copy_is_vaapi() {
if let Some(m) = vaapi_codec_support().wire_mask() {
return m;
}
}
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
GPU_SUPERSET
}
#[cfg(target_os = "windows")]
{
if windows_resolved_backend() == WindowsBackend::Software {
return punktfunk_core::quic::CODEC_H264;
}
if windows_backend_is_probed() {
if let Some(m) = windows_codec_support().wire_mask() {
return m;
}
}
// NVENC (static superset, like GameStream) — or an empty AMF/QSV probe (see above).
GPU_SUPERSET
}
// The macOS dev/test host has no GPU encode backend — keep the pre-probe advertisement.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
let _ = GPU_SUPERSET;
match crate::config::config().encoder_pref.as_str() {
"software" | "sw" | "openh264" => punktfunk_core::quic::CODEC_H264,
_ => punktfunk_core::quic::CODEC_HEVC,
}
}
}
/// 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",
} }
} }
@@ -741,6 +795,27 @@ pub struct CodecSupport {
pub av1: bool, pub av1: bool,
} }
#[cfg(any(target_os = "linux", target_os = "windows"))]
impl CodecSupport {
/// The probed codecs as a `quic::CODEC_*` bitfield, or `None` when the probe found nothing —
/// meaning the GPU wasn't usable at probe time (GPU-less CI, a wrong-vendor pref), NOT that it
/// encodes zero codecs; the caller then falls back to the static superset (the native-path
/// analogue of `gamestream::serverinfo::probed_mask`).
pub fn wire_mask(self) -> Option<u8> {
let mut m = 0u8;
if self.h264 {
m |= punktfunk_core::quic::CODEC_H264;
}
if self.h265 {
m |= punktfunk_core::quic::CODEC_HEVC;
}
if self.av1 {
m |= punktfunk_core::quic::CODEC_AV1;
}
(m != 0).then_some(m)
}
}
/// Probe the active Linux GPU backend for its encodable codecs (cached; opens a tiny encoder per /// Probe the active Linux GPU backend for its encodable codecs (cached; opens a tiny encoder per
/// codec, once). Only the VAAPI (AMD/Intel) backend is probed — NVENC keeps its Moonlight-validated /// codec, once). Only the VAAPI (AMD/Intel) backend is probed — NVENC keeps its Moonlight-validated
/// static advertisement (callers gate on [`linux_zero_copy_is_vaapi`]). /// static advertisement (callers gate on [`linux_zero_copy_is_vaapi`]).
@@ -1042,4 +1117,42 @@ mod tests {
} }
} }
} }
/// The probed-capability → wire-bitfield mapping the native codec advertisement is built from.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[test]
fn codec_support_wire_mask() {
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC};
let all = CodecSupport {
h264: true,
h265: true,
av1: true,
};
assert_eq!(all.wire_mask(), Some(CODEC_H264 | CODEC_HEVC | CODEC_AV1));
let hevc_only = CodecSupport {
h264: false,
h265: true,
av1: false,
};
assert_eq!(hevc_only.wire_mask(), Some(CODEC_HEVC));
// An all-false probe means "GPU unusable at probe time", not "zero codecs" — `None` tells
// the caller to advertise the static superset instead of refusing every handshake.
let none = CodecSupport {
h264: false,
h265: false,
av1: false,
};
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");
}
} }
@@ -663,11 +663,7 @@ fn stream_body(
// Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors // Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors
// for p50/p99, the goodput bytes queued to the sender this window, the previous window's // for p50/p99, the goodput bytes queued to the sender this window, the previous window's
// dropped-frame count for delta computation, and the registration id cached on the first sample. // dropped-frame count for delta computation, and the registration id cached on the first sample.
let codec_name = match cfg.codec { let codec_name = cfg.codec.label();
Codec::H264 => "h264",
Codec::H265 => "hevc",
Codec::Av1 => "av1",
};
let mut sid: Option<u32> = None; let mut sid: Option<u32> = None;
let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec<u32>, Vec<u32>, Vec<u32>, Vec<u32>) = let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec<u32>, Vec<u32>, Vec<u32>, Vec<u32>) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new()); (Vec::new(), Vec::new(), Vec::new(), Vec::new());
+20 -11
View File
@@ -732,9 +732,10 @@ async fn serve_session(
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above, // The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
// before this future, so a client reaching here is paired (or the host is `--open`). // before this future, so a client reaching here is paired (or the host is `--open`).
// Codec negotiation: pick the one codec this host will emit (its backend capability ∩ the // Codec negotiation: pick the one codec this host will emit (its GPU-probed backend
// client's advertised codecs). A GPU-less software host emits H.264, so an HEVC-only client // capability ∩ the client's advertised codecs, honoring the client's soft preference).
// shares nothing with it → refuse honestly rather than send a stream it can't decode. // A GPU-less software host emits H.264 only, so an HEVC-only client shares nothing with
// it → refuse honestly rather than send a stream it can't decode.
let host_codecs = crate::encode::Codec::host_wire_caps(); let host_codecs = crate::encode::Codec::host_wire_caps();
let codec_bit = let codec_bit =
punktfunk_core::quic::resolve_codec(hello.video_codecs, host_codecs, hello.preferred_codec) punktfunk_core::quic::resolve_codec(hello.video_codecs, host_codecs, hello.preferred_codec)
@@ -888,10 +889,16 @@ async fn serve_session(
// Resolve the encode bit depth: HEVC Main10 only when the client advertised it AND the host // Resolve the encode bit depth: HEVC Main10 only when the client advertised it AND the host
// opted in (PUNKTFUNK_10BIT). A client that can't decode 10-bit (caps bit clear, or an older // opted in (PUNKTFUNK_10BIT). A client that can't decode 10-bit (caps bit clear, or an older
// client) always gets the 8-bit stream. PUNKTFUNK_10BIT is the host policy gate until a // client) always gets the 8-bit stream. PUNKTFUNK_10BIT is the host policy gate until a
// mgmt/console toggle replaces it. // mgmt/console toggle replaces it. 10-bit is HEVC-only (like the 4:4:4 gate below): now that
// the client can steer the codec to H.264/AV1, a non-HEVC session must stay 8-bit — the
// encoders' 10-bit path is Main10, and AV1 10-bit stays off until live-confirmed (the same
// stance as the GameStream Main10 advertisement).
let host_wants_10bit = crate::config::config().ten_bit; let host_wants_10bit = crate::config::config().ten_bit;
let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0; let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
let bit_depth: u8 = if host_wants_10bit && client_supports_10bit { let bit_depth: u8 = if host_wants_10bit
&& client_supports_10bit
&& codec == crate::encode::Codec::H265
{
10 10
} else { } else {
8 8
@@ -1010,8 +1017,9 @@ async fn serve_session(
// The resolved audio channel count the audio thread will capture + Opus-(multi)stream // The resolved audio channel count the audio thread will capture + Opus-(multi)stream
// encode (2/6/8). The client builds its decoder from this echoed value. // encode (2/6/8). The client builds its decoder from this echoed value.
audio_channels, audio_channels,
// The negotiated codec the encoder will emit (H.264 for a software host, else HEVC). The // The negotiated codec the encoder will emit (client preference ∩ GPU capability;
// client builds its decoder from this instead of assuming HEVC. // HEVC-precedence tie-break). The client builds its decoder from this instead of
// assuming HEVC.
codec: codec_bit, codec: codec_bit,
}; };
io::write_msg(&mut send, &welcome.encode()).await?; io::write_msg(&mut send, &welcome.encode()).await?;
@@ -1027,7 +1035,7 @@ async fn serve_session(
.await .await
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
let (mut ctrl_send, mut ctrl_recv) = (send, recv); let (mut ctrl_send, mut ctrl_recv) = (send, recv);
// Negotiated codec (HEVC / H.264), derived from the Welcome. `Copy`, so the control task's // Negotiated codec (HEVC / H.264 / AV1), derived from the Welcome. `Copy`, so the control task's
// `async move` captures a copy and it stays usable for the data-plane SessionContext below. // `async move` captures a copy and it stays usable for the data-plane SessionContext below.
let codec = crate::encode::Codec::from_wire(welcome.codec); let codec = crate::encode::Codec::from_wire(welcome.codec);
let client_udp = std::net::SocketAddr::new(peer.ip(), start.client_udp_port); let client_udp = std::net::SocketAddr::new(peer.ip(), start.client_udp_port);
@@ -3080,8 +3088,9 @@ struct SessionContext {
bit_depth: u8, bit_depth: u8,
/// Negotiated chroma subsampling (4:2:0, or 4:4:4 when the client + host + GPU all support it). /// Negotiated chroma subsampling (4:2:0, or 4:4:4 when the client + host + GPU all support it).
chroma: crate::encode::ChromaFormat, chroma: crate::encode::ChromaFormat,
/// Negotiated video codec the encoder emits (HEVC by default; H.264 for a software host). Also /// Negotiated video codec the encoder emits (HEVC by default; H.264 / AV1 when the client
/// used to rebuild the encoder at the same codec across a mid-stream mode reconfigure. /// prefers one the GPU encodes; H.264 for a software host). Also used to rebuild the encoder
/// at the same codec across a mid-stream mode reconfigure.
codec: crate::encode::Codec, codec: crate::encode::Codec,
/// Speed-test burst requests (see [`service_probes`]). /// Speed-test burst requests (see [`service_probes`]).
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>, probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
@@ -3236,7 +3245,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
width: mode.width, width: mode.width,
height: mode.height, height: mode.height,
fps: mode.refresh_hz, fps: mode.refresh_hz,
codec: "hevc", codec: plan.codec.label(),
client: client_label, client: client_label,
bitrate_kbps, bitrate_kbps,
}; };