feat(encode): RGB-direct (EFC) is now the DEFAULT on capable hosts — gated by a session cursor-blend hint
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
android / android (push) Has been cancelled
ci / web (push) Successful in 55s
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
ci / docs-site (push) Successful in 55s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
ci / bench (push) Successful in 7m8s
ci / rust (push) Failing after 9m14s
windows-host / package (push) Successful in 10m24s
arch / build-publish (push) Successful in 12m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m25s
docker / deploy-docs (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 20m25s
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
android / android (push) Has been cancelled
ci / web (push) Successful in 55s
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
ci / docs-site (push) Successful in 55s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
ci / bench (push) Successful in 7m8s
ci / rust (push) Failing after 9m14s
windows-host / package (push) Successful in 10m24s
arch / build-publish (push) Successful in 12m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m25s
docker / deploy-docs (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 20m25s
B2 default-on (design/vulkan-rgb-direct-encode.md): wherever the probe
passes, Vulkan Video sessions now take the EFC RGB source by default —
direct import on aligned modes, padded-copy staging on unaligned ones
(1080p). PUNKTFUNK_VULKAN_RGB_DIRECT becomes the override: =0 disables,
=1 forces, unset = the default below.
The one session class that must NOT default on: cursor-as-metadata
captures (every non-gamescope compositor), where the CSC shader's blend
IS the visible pointer — the EFC cannot composite, and defaulting there
would silently drop the cursor from the stream. The hint rides the
existing plumbing:
- SessionPlan gains cursor_blend, resolved once where the compositor is
known (gamescope embeds the pointer itself → false; kwin/mutter/
wlroots/hyprland → true), and shows up in the logged plan line.
- open_video/open_video_backend thread it through (native pump: all
three encoder-open sites read plan.cursor_blend; GameStream monitor
capture: true — it negotiates metadata cursor; spike: false).
- VulkanVideoEncoder::open resolves: env override, else ON iff the
session never hands us cursor bitmaps. The warn-once for a cursor on
an RGB session (forced via =1) stays.
Verified on-hw box (Linux): pf-encode + punktfunk-host compile, clippy
clean, unit suite green. The GPU paths themselves are unchanged from the
smoke-validated 96e19986 — this commit only changes which sessions
select them by default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,12 +69,19 @@ fn quality_request() -> u32 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUNKTFUNK_VULKAN_RGB_DIRECT=1` opts into the RGB-direct encode source (B1,
|
/// `PUNKTFUNK_VULKAN_RGB_DIRECT` override for the RGB-direct encode source
|
||||||
/// design/vulkan-rgb-direct-encode.md): the captured RGB frame is handed to the encoder as-is
|
/// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the
|
||||||
/// and the VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies,
|
/// VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies, one
|
||||||
/// one queue submit per frame. Default OFF until the color/latency A/B graduates it (B2).
|
/// queue submit per frame (unaligned modes go through the padded-copy staging blit). B2
|
||||||
fn rgb_request() -> bool {
|
/// default: ON wherever the probe passes, EXCEPT sessions that may need the CSC's cursor
|
||||||
std::env::var("PUNKTFUNK_VULKAN_RGB_DIRECT").is_ok_and(|v| v == "1")
|
/// blend (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it even on
|
||||||
|
/// cursor-blend sessions (the pointer will be missing from the stream); unset = the default.
|
||||||
|
fn rgb_request() -> Option<bool> {
|
||||||
|
match std::env::var("PUNKTFUNK_VULKAN_RGB_DIRECT") {
|
||||||
|
Ok(v) if v == "0" => Some(false),
|
||||||
|
Ok(_) => Some(true),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live RGB-direct session config: the chroma-siting bits the session was created with
|
/// Live RGB-direct session config: the chroma-siting bits the session was created with
|
||||||
@@ -408,9 +415,21 @@ pub struct VulkanVideoEncoder {
|
|||||||
unsafe impl Send for VulkanVideoEncoder {}
|
unsafe impl Send for VulkanVideoEncoder {}
|
||||||
|
|
||||||
impl VulkanVideoEncoder {
|
impl VulkanVideoEncoder {
|
||||||
/// Signature mirrors the other Linux backends' `open` (see `nvenc_cuda::NvencCudaEncoder::open`).
|
/// Signature mirrors the other Linux backends' `open` plus `cursor_blend`: the session may
|
||||||
pub fn open(codec: Codec, width: u32, height: u32, fps: u32, bitrate_bps: u64) -> Result<Self> {
|
/// hand this encoder cursor bitmaps to composite (cursor-as-metadata captures — every
|
||||||
Self::open_opts(codec, width, height, fps, bitrate_bps, rgb_request())
|
/// non-gamescope compositor). The EFC cannot blend, so such sessions default to the CSC
|
||||||
|
/// path; everywhere else the RGB-direct source is the DEFAULT wherever the probe passes
|
||||||
|
/// (B2). `PUNKTFUNK_VULKAN_RGB_DIRECT` overrides both ways (see [`rgb_request`]).
|
||||||
|
pub fn open(
|
||||||
|
codec: Codec,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fps: u32,
|
||||||
|
bitrate_bps: u64,
|
||||||
|
cursor_blend: bool,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let want_rgb = rgb_request().unwrap_or(!cursor_blend);
|
||||||
|
Self::open_opts(codec, width, height, fps, bitrate_bps, want_rgb)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `open` with the RGB-direct request explicit instead of read from the env — the smoke
|
/// `open` with the RGB-direct request explicit instead of read from the env — the smoke
|
||||||
@@ -563,7 +582,9 @@ impl VulkanVideoEncoder {
|
|||||||
(_, _, Some(RgbDirect { padded: true, .. })) =>
|
(_, _, Some(RgbDirect { padded: true, .. })) =>
|
||||||
"active(padded-copy: mode is not 64x16-aligned — staging blit + edge \
|
"active(padded-copy: mode is not 64x16-aligned — staging blit + edge \
|
||||||
duplication instead of the direct import)",
|
duplication instead of the direct import)",
|
||||||
(Ok(_), false, None) => "available(off; set PUNKTFUNK_VULKAN_RGB_DIRECT=1)",
|
(Ok(_), false, None) =>
|
||||||
|
"available(off: PUNKTFUNK_VULKAN_RGB_DIRECT=0, or a cursor-blend session \
|
||||||
|
— =1 forces)",
|
||||||
(Err(e), _, None) => e,
|
(Err(e), _, None) => e,
|
||||||
// (Ok, wanted) always builds Some above.
|
// (Ok, wanted) always builds Some above.
|
||||||
(Ok(_), true, None) => unreachable!("rgb gate and cfg disagree"),
|
(Ok(_), true, None) => unreachable!("rgb gate and cfg disagree"),
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ pub fn open_video(
|
|||||||
cuda: bool,
|
cuda: bool,
|
||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
chroma: ChromaFormat,
|
chroma: ChromaFormat,
|
||||||
|
// The session may hand this encoder cursor bitmaps to composite (cursor-as-metadata
|
||||||
|
// captures). Backends whose fast path can't blend (Vulkan EFC RGB-direct) key off it.
|
||||||
|
cursor_blend: bool,
|
||||||
) -> Result<Box<dyn Encoder>> {
|
) -> Result<Box<dyn Encoder>> {
|
||||||
let (inner, backend) = open_video_backend(
|
let (inner, backend) = open_video_backend(
|
||||||
codec,
|
codec,
|
||||||
@@ -140,6 +143,7 @@ pub fn open_video(
|
|||||||
cuda,
|
cuda,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
chroma,
|
chroma,
|
||||||
|
cursor_blend,
|
||||||
)?;
|
)?;
|
||||||
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
|
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
|
||||||
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
|
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
|
||||||
@@ -264,7 +268,9 @@ fn open_video_backend(
|
|||||||
cuda: bool,
|
cuda: bool,
|
||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
chroma: ChromaFormat,
|
chroma: ChromaFormat,
|
||||||
|
cursor_blend: bool,
|
||||||
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||||
|
let _ = cursor_blend; // consumed only by the Linux vulkan-encode arm below
|
||||||
validate_dimensions(codec, width, height)?;
|
validate_dimensions(codec, width, height)?;
|
||||||
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
||||||
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
||||||
@@ -322,8 +328,14 @@ fn open_video_backend(
|
|||||||
&& vulkan_encode_enabled()
|
&& vulkan_encode_enabled()
|
||||||
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
||||||
{
|
{
|
||||||
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
match vulkan_video::VulkanVideoEncoder::open(
|
||||||
{
|
codec,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
bitrate_bps,
|
||||||
|
cursor_blend,
|
||||||
|
) {
|
||||||
Ok(e) => {
|
Ok(e) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
codec = ?codec,
|
codec = ?codec,
|
||||||
@@ -376,8 +388,15 @@ fn open_video_backend(
|
|||||||
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
|
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
vulkan_video::VulkanVideoEncoder::open(
|
||||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
|
codec,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
bitrate_bps,
|
||||||
|
cursor_blend,
|
||||||
|
)
|
||||||
|
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "vulkan-encode"))]
|
#[cfg(not(feature = "vulkan-encode"))]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -658,6 +658,9 @@ fn stream_body(
|
|||||||
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
||||||
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
||||||
encode::ChromaFormat::Yuv420,
|
encode::ChromaFormat::Yuv420,
|
||||||
|
// Desktop monitor capture negotiates cursor-as-metadata where available — the encoder
|
||||||
|
// may be handed cursor bitmaps to composite.
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
.context("open video encoder for stream")?;
|
.context("open video encoder for stream")?;
|
||||||
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
||||||
@@ -811,6 +814,7 @@ fn stream_body(
|
|||||||
frame.is_cuda(),
|
frame.is_cuda(),
|
||||||
gs_bit_depth(frame.format),
|
gs_bit_depth(frame.format),
|
||||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||||
|
true, // metadata-cursor capture — see the first open
|
||||||
)
|
)
|
||||||
.context("reopen encoder after rebuild")?;
|
.context("reopen encoder after rebuild")?;
|
||||||
supports_rfi = enc.caps().supports_rfi;
|
supports_rfi = enc.caps().supports_rfi;
|
||||||
|
|||||||
@@ -983,7 +983,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site
|
// path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site
|
||||||
// (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the
|
// (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the
|
||||||
// only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`.
|
// only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`.
|
||||||
let mut plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth, ctx.chroma, ctx.codec);
|
let mut plan = crate::session_plan::SessionPlan::resolve(
|
||||||
|
ctx.bit_depth,
|
||||||
|
ctx.chroma,
|
||||||
|
ctx.codec,
|
||||||
|
ctx.compositor != pf_vdisplay::Compositor::Gamescope,
|
||||||
|
);
|
||||||
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
|
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
|
||||||
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
|
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
|
||||||
if ctx.codec == crate::encode::Codec::PyroWave {
|
if ctx.codec == crate::encode::Codec::PyroWave {
|
||||||
@@ -1632,6 +1637,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
frame.is_cuda(),
|
frame.is_cuda(),
|
||||||
bit_depth,
|
bit_depth,
|
||||||
plan.chroma,
|
plan.chroma,
|
||||||
|
plan.cursor_blend,
|
||||||
) {
|
) {
|
||||||
Ok(mut new_enc) => {
|
Ok(mut new_enc) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -2551,6 +2557,7 @@ fn try_inplace_resize(
|
|||||||
new_frame.is_cuda(),
|
new_frame.is_cuda(),
|
||||||
bit_depth,
|
bit_depth,
|
||||||
plan.chroma,
|
plan.chroma,
|
||||||
|
plan.cursor_blend,
|
||||||
) {
|
) {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -2619,7 +2626,12 @@ pub(super) fn prepare_display(
|
|||||||
// Same plan resolution as `virtual_stream` (pure in these inputs + host config), including
|
// Same plan resolution as `virtual_stream` (pure in these inputs + host config), including
|
||||||
// PyroWave's datagram-aligned wire mode — `Session::shard_payload()` echoes the negotiated
|
// PyroWave's datagram-aligned wire mode — `Session::shard_payload()` echoes the negotiated
|
||||||
// Welcome value passed here.
|
// Welcome value passed here.
|
||||||
let mut plan = crate::session_plan::SessionPlan::resolve(bit_depth, chroma, codec);
|
let mut plan = crate::session_plan::SessionPlan::resolve(
|
||||||
|
bit_depth,
|
||||||
|
chroma,
|
||||||
|
codec,
|
||||||
|
compositor != pf_vdisplay::Compositor::Gamescope,
|
||||||
|
);
|
||||||
if codec == crate::encode::Codec::PyroWave {
|
if codec == crate::encode::Codec::PyroWave {
|
||||||
plan.wire_chunk = Some(shard_payload as usize);
|
plan.wire_chunk = Some(shard_payload as usize);
|
||||||
}
|
}
|
||||||
@@ -2871,6 +2883,7 @@ fn build_pipeline(
|
|||||||
frame.is_cuda(),
|
frame.is_cuda(),
|
||||||
bit_depth,
|
bit_depth,
|
||||||
plan.chroma,
|
plan.chroma,
|
||||||
|
plan.cursor_blend,
|
||||||
)
|
)
|
||||||
.context("open video encoder")?;
|
.context("open video encoder")?;
|
||||||
if let Some(t) = trace {
|
if let Some(t) = trace {
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ pub struct SessionPlan {
|
|||||||
/// PyroWave session — applied to EVERY encoder this plan opens (initial + all rebuilds) so
|
/// PyroWave session — applied to EVERY encoder this plan opens (initial + all rebuilds) so
|
||||||
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
|
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
|
||||||
pub wire_chunk: Option<usize>,
|
pub wire_chunk: Option<usize>,
|
||||||
|
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
|
||||||
|
/// captures — every non-gamescope compositor; gamescope embeds the pointer itself).
|
||||||
|
/// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their
|
||||||
|
/// blending path when this is set, so the pointer never silently vanishes from the stream.
|
||||||
|
pub cursor_blend: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionPlan {
|
impl SessionPlan {
|
||||||
@@ -112,6 +117,7 @@ impl SessionPlan {
|
|||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
chroma: crate::encode::ChromaFormat,
|
chroma: crate::encode::ChromaFormat,
|
||||||
codec: crate::encode::Codec,
|
codec: crate::encode::Codec,
|
||||||
|
cursor_blend: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
SessionPlan {
|
SessionPlan {
|
||||||
capture: CaptureBackend::resolve(),
|
capture: CaptureBackend::resolve(),
|
||||||
@@ -122,6 +128,7 @@ impl SessionPlan {
|
|||||||
chroma,
|
chroma,
|
||||||
codec,
|
codec,
|
||||||
wire_chunk: None,
|
wire_chunk: None,
|
||||||
|
cursor_blend,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ pub fn run(opts: Options) -> Result<()> {
|
|||||||
first.is_cuda(),
|
first.is_cuda(),
|
||||||
8, // spike synthetic harness: 8-bit
|
8, // spike synthetic harness: 8-bit
|
||||||
encode::ChromaFormat::Yuv420, // ...and 4:2:0
|
encode::ChromaFormat::Yuv420, // ...and 4:2:0
|
||||||
|
false, // synthetic frames carry no cursor
|
||||||
)
|
)
|
||||||
.context("open encoder")?;
|
.context("open encoder")?;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user