feat(clients): render-scale setting on every client — shared punktfunk_core::render_scale
Client-side supersampling/downscaling: the client asks the host to render and encode at chosen-resolution × scale (the host does no scaling) and the presenter rescales the decoded frame to the display. >1 supersamples for sharpness; <1 lightens the host GPU and the link. Default 1.0 = Native, the prior behavior. The geometry lives once in punktfunk_core::render_scale (multiply, preserve aspect ratio, floor to even, clamp to the codec's per-axis ceiling — 4096 for H.264, 8192 otherwise), the Rust twin of the Apple client's RenderScale.swift, consumed by the native session client, the presenter's match-window path, the Windows/Linux settings UIs, Decky, and Android (settings + host connect + unit test). Implemented and platform-verified by the Apple-client-features session (Linux+Android+Apple green there); the punktfunk-core wiring (pub mod render_scale) is restored here after being lost in a working-tree reconciliation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -461,6 +461,14 @@ pub struct Settings {
|
||||
pub refresh_hz: u32,
|
||||
/// Requested encoder bitrate (kbps); 0 = host default.
|
||||
pub bitrate_kbps: u32,
|
||||
/// Render-resolution multiplier: the client asks the host to render/encode at
|
||||
/// `resolved mode × render_scale` and the presenter downscales the larger decoded frame to the
|
||||
/// window (`> 1` supersamples for sharpness, at more bandwidth AND decode; `< 1` renders under
|
||||
/// native for a lighter host/link). `1.0` = Native (the prior behaviour). Applied at connect
|
||||
/// (and each match-window resize) via [`punktfunk_core::render_scale`], clamped even + to the
|
||||
/// codec's max dimension. Missing in a pre-existing store → the `Default` (1.0) via the
|
||||
/// container `#[serde(default)]`.
|
||||
pub render_scale: f64,
|
||||
pub gamepad: String,
|
||||
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
|
||||
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
||||
@@ -590,6 +598,7 @@ impl Default for Settings {
|
||||
height: 0,
|
||||
refresh_hz: 0,
|
||||
bitrate_kbps: 0,
|
||||
render_scale: 1.0,
|
||||
gamepad: "auto".into(),
|
||||
forward_pad: String::new(),
|
||||
compositor: "auto".into(),
|
||||
|
||||
@@ -69,6 +69,13 @@ pub struct SessionOpts {
|
||||
/// persists it for the next launch. `None` = never auto-resize (Auto-native /
|
||||
/// Explicit keep today's behavior).
|
||||
pub match_window: Option<Box<dyn FnMut(u32, u32)>>,
|
||||
/// Render-resolution multiplier applied to the window pixel size under Match-window (the
|
||||
/// fixed-mode path scales in the binary's `session_params`). `> 1` supersamples (host renders
|
||||
/// larger, the presenter downscales); `1.0` = the window's native pixels. See
|
||||
/// [`punktfunk_core::render_scale`].
|
||||
pub render_scale: f64,
|
||||
/// The codec's per-axis ceiling for the render-scale clamp (4096 for H.264, else 8192).
|
||||
pub render_scale_max_dim: u32,
|
||||
}
|
||||
|
||||
pub enum Outcome {
|
||||
@@ -411,7 +418,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
presenter.vulkan_decode(),
|
||||
);
|
||||
if opts.match_window.is_some() {
|
||||
apply_match_window(&mut params, &window);
|
||||
apply_match_window(
|
||||
&mut params,
|
||||
&window,
|
||||
opts.render_scale,
|
||||
opts.render_scale_max_dim,
|
||||
);
|
||||
}
|
||||
Some(StreamState::new(
|
||||
params,
|
||||
@@ -749,7 +761,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
ActionOutcome::Handled => {}
|
||||
ActionOutcome::Start(mut params) => {
|
||||
if opts.match_window.is_some() {
|
||||
apply_match_window(&mut params, &window);
|
||||
apply_match_window(
|
||||
&mut params,
|
||||
&window,
|
||||
opts.render_scale,
|
||||
opts.render_scale_max_dim,
|
||||
);
|
||||
}
|
||||
stream = Some(StreamState::new(
|
||||
*params,
|
||||
@@ -896,7 +913,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// --- Match-window (D2): debounced mode-follow ----
|
||||
if let Some(persist) = opts.match_window.as_mut() {
|
||||
if let Some(st) = stream.as_mut() {
|
||||
resize_tick(st, &mut window, persist.as_mut());
|
||||
resize_tick(
|
||||
st,
|
||||
&mut window,
|
||||
persist.as_mut(),
|
||||
opts.render_scale,
|
||||
opts.render_scale_max_dim,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Resize overlay timeout: a switch the host rejected/capped never delivers the exact
|
||||
@@ -1212,14 +1235,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
/// size — even-floored (the host's `validate_dimensions` rejects odd) and clamped to a
|
||||
/// sane minimum — keeping the resolved refresh. Under `--fullscreen` the window IS the
|
||||
/// display, so this degenerates to the display's native mode.
|
||||
fn apply_match_window(params: &mut SessionParams, window: &sdl3::video::Window) {
|
||||
fn apply_match_window(
|
||||
params: &mut SessionParams,
|
||||
window: &sdl3::video::Window,
|
||||
render_scale: f64,
|
||||
max_dim: u32,
|
||||
) {
|
||||
let (pw, ph) = window.size_in_pixels();
|
||||
params.mode.width = (pw & !1).max(320);
|
||||
params.mode.height = (ph & !1).max(200);
|
||||
// × the render scale (even + codec-clamped), so match-window supersamples/undersamples exactly
|
||||
// like the fixed-mode path; 1.0 leaves the window's native pixels (the prior behaviour).
|
||||
let (w, h) = punktfunk_core::render_scale::apply(pw, ph, render_scale, max_dim);
|
||||
params.mode.width = w;
|
||||
params.mode.height = h;
|
||||
tracing::info!(
|
||||
w = params.mode.width,
|
||||
h = params.mode.height,
|
||||
"match-window: requesting the window's pixel size"
|
||||
w,
|
||||
h,
|
||||
"match-window: requesting the scaled window pixel size"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1250,18 +1281,25 @@ fn resize_tick(
|
||||
st: &mut StreamState,
|
||||
window: &mut sdl3::video::Window,
|
||||
persist: &mut dyn FnMut(u32, u32),
|
||||
render_scale: f64,
|
||||
max_dim: u32,
|
||||
) {
|
||||
let Some(c) = &st.connector else {
|
||||
return; // not connected yet — the pending stamp survives until we are
|
||||
};
|
||||
let m = c.mode();
|
||||
// × the render scale (even + codec-clamped) so a resize under Match-window targets the same
|
||||
// supersampled space the live mode is in; 1.0 leaves the window's native pixels. resize_decision
|
||||
// re-normalizes idempotently.
|
||||
let (pw, ph) = window.size_in_pixels();
|
||||
let pixel_size = punktfunk_core::render_scale::apply(pw, ph, render_scale, max_dim);
|
||||
match resize_decision(
|
||||
Instant::now(),
|
||||
&mut st.resize_pending,
|
||||
st.resize_sent_at,
|
||||
st.resize_requested,
|
||||
(m.width, m.height),
|
||||
window.size_in_pixels(),
|
||||
pixel_size,
|
||||
) {
|
||||
ResizeAction::Wait => {}
|
||||
ResizeAction::Settled(target) => {
|
||||
|
||||
@@ -45,6 +45,7 @@ pub mod packet;
|
||||
pub mod quic;
|
||||
pub mod reanchor;
|
||||
pub mod reject;
|
||||
pub mod render_scale;
|
||||
pub mod session;
|
||||
pub mod stats;
|
||||
#[cfg(feature = "tls")]
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Render-resolution scaling — the pure geometry behind the clients' render-scale setting.
|
||||
//!
|
||||
//! Client-side supersampling: a client asks the host to render/encode at `chosen resolution ×
|
||||
//! scale` (a normal larger/smaller [`Mode`](crate::Mode) — the host does no scaling), then its
|
||||
//! presenter downscales the larger decoded frame to the display (`> 1` = supersampling for
|
||||
//! sharpness) or upscales a smaller one (`< 1` = a lighter host GPU / thinner link). This module is
|
||||
//! where the multiplier becomes a host-valid mode dimension: multiply, preserve the aspect ratio,
|
||||
//! floor to even (the host's `validate_dimensions` rejects odd sizes), and clamp to the codec's
|
||||
//! per-axis ceiling so a connect can't request a size the encoder will reject.
|
||||
//!
|
||||
//! It is the Rust twin of the Apple client's `PunktfunkShared/RenderScale.swift` and is shared by
|
||||
//! the native (Linux/`clients/session`, Windows) clients and the Android JNI path — all of which
|
||||
//! reach `punktfunk-core`. Kept dependency-free + side-effect-free so it is unit-tested here.
|
||||
|
||||
/// Minimum supported multiplier (renders under native, upscaled on present).
|
||||
pub const MIN_SCALE: f64 = 0.5;
|
||||
/// Maximum supported multiplier (supersamples, clamped to the codec ceiling per axis).
|
||||
pub const MAX_SCALE: f64 = 4.0;
|
||||
|
||||
/// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops
|
||||
/// users reason about. Shared so every client's list stays identical.
|
||||
pub const PRESETS: [f64; 9] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
|
||||
/// The encoder/host per-axis ceiling for a codec preference string (the clients' `codec` setting:
|
||||
/// `"auto"`/`"hevc"`/`"h264"`/`"av1"`/`"pyrowave"`). H.264 tops out at 4096 px/axis; everything else
|
||||
/// (incl. "auto", which negotiates HEVC/AV1 in practice) at 8192 — the same walls the host enforces
|
||||
/// in `pf-encode`'s `codec.rs::max_dimension`.
|
||||
pub fn max_dimension(codec: &str) -> u32 {
|
||||
if codec == "h264" {
|
||||
4096
|
||||
} else {
|
||||
8192
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp a raw stored multiplier into `[MIN_SCALE, MAX_SCALE]`, treating a missing / non-positive /
|
||||
/// NaN value as `1.0` (Native).
|
||||
pub fn sanitize(raw: f64) -> f64 {
|
||||
if raw.is_nan() || raw <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
raw.clamp(MIN_SCALE, MAX_SCALE)
|
||||
}
|
||||
|
||||
/// Apply `scale` to a base pixel size: preserve aspect, even-floor each axis, and clamp uniformly so
|
||||
/// neither axis exceeds `max_dim` (the larger axis lands on the cap, the ratio is kept). Also floors
|
||||
/// each axis at 320×200 (the host never accepts smaller). The result is a directly host-valid
|
||||
/// [`Mode`](crate::Mode) width/height.
|
||||
pub fn apply(base_w: u32, base_h: u32, scale: f64, max_dim: u32) -> (u32, u32) {
|
||||
let scale = sanitize(scale);
|
||||
let mut w = base_w.max(1) as f64 * scale;
|
||||
let mut h = base_h.max(1) as f64 * scale;
|
||||
// Uniform down-clamp if either axis blew past the ceiling — keep the aspect ratio intact.
|
||||
let cap = max_dim as f64;
|
||||
let over = (w / cap).max(h / cap);
|
||||
if over > 1.0 {
|
||||
w /= over;
|
||||
h /= over;
|
||||
}
|
||||
(even_floor(w, 320), even_floor(h, 200))
|
||||
}
|
||||
|
||||
/// Floor a dimension to an even integer, not below `minimum` (also even-floored).
|
||||
fn even_floor(value: f64, minimum: u32) -> u32 {
|
||||
let v = (value.floor() as i64).max(minimum as i64).max(0) as u32;
|
||||
v / 2 * 2
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_clamps_and_defaults() {
|
||||
assert_eq!(sanitize(0.0), 1.0);
|
||||
assert_eq!(sanitize(-3.0), 1.0);
|
||||
assert_eq!(sanitize(f64::NAN), 1.0);
|
||||
assert_eq!(sanitize(0.1), 0.5);
|
||||
assert_eq!(sanitize(9.0), 4.0);
|
||||
assert_eq!(sanitize(1.5), 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_dimension_is_codec_aware() {
|
||||
assert_eq!(max_dimension("h264"), 4096);
|
||||
assert_eq!(max_dimension("hevc"), 8192);
|
||||
assert_eq!(max_dimension("av1"), 8192);
|
||||
assert_eq!(max_dimension("auto"), 8192);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_is_identity() {
|
||||
assert_eq!(apply(1920, 1080, 1.0, 8192), (1920, 1080));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supersample_doubles() {
|
||||
assert_eq!(apply(1920, 1080, 2.0, 8192), (3840, 2160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn under_render_halves() {
|
||||
assert_eq!(apply(1920, 1080, 0.5, 8192), (960, 540));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_are_even() {
|
||||
// 1366×768 × 1.5 = 2049×1152 → even-floored to 2048×1152.
|
||||
let (w, h) = apply(1366, 768, 1.5, 8192);
|
||||
assert_eq!(w % 2, 0);
|
||||
assert_eq!(h % 2, 0);
|
||||
assert_eq!((w, h), (2048, 1152));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_ceiling_clamps_uniformly() {
|
||||
// 4K × 4 = 15360×8640; both exceed 8192 → width lands on cap, 16:9 kept (8192×4608).
|
||||
let (w, h) = apply(3840, 2160, 4.0, 8192);
|
||||
assert!(w <= 8192 && h <= 8192);
|
||||
assert_eq!((w, h), (8192, 4608));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_ceiling_is_tighter() {
|
||||
// 1080p × 4 = 7680×4320; under H.264's 4096 wall → 4096×2304.
|
||||
assert_eq!(apply(1920, 1080, 4.0, 4096), (4096, 2304));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_floor_honoured() {
|
||||
let (w, h) = apply(400, 300, 0.5, 8192);
|
||||
assert!(w >= 320 && h >= 200);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user