diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt index 5ce668a7..65992f38 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt @@ -30,7 +30,13 @@ suspend fun connectToHost( ): Long { // Advertise HDR only when the user enabled it AND this device's display can present it (else the // host sends a proper SDR stream rather than PQ the panel would mis-tone-map). - val (w, h, hz) = settings.effectiveMode(context) + val (baseW, baseH, hz) = settings.effectiveMode(context) + // Render scale: ask the host for `chosen mode × scale` (even + codec-clamped) — > 1 supersamples + // (the compositor downscales the larger decoded frame to the SurfaceView), < 1 renders under + // native. 1.0 leaves the resolved mode untouched. + val (w, h) = RenderScale.apply( + baseW, baseH, settings.renderScale, RenderScale.maxDimension(settings.codec) + ) val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context) // "Automatic" resolves to a concrete pad type from the connected controller's VID/PID. val gamepadPref = Gamepad.resolvePref(settings.gamepad) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 194c52c8..03bcb7c1 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -16,6 +16,14 @@ data class Settings( val height: Int = 0, val hz: Int = 0, val bitrateKbps: Int = 0, + /** + * Render-resolution multiplier: the client asks the host to render/encode at `chosen mode × + * renderScale` and the compositor downscales the larger decoded frame to the SurfaceView + * (`> 1` supersamples for sharpness, at more bandwidth AND decode; `< 1` renders under native + * for a lighter host/link). `1.0` = Native. Applied at connect via [RenderScale.apply], clamped + * even + to the codec's max dimension. Mirrors the Apple/Linux clients' render scale. + */ + val renderScale: Double = 1.0, /** * Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that * can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never @@ -137,6 +145,7 @@ class SettingsStore(context: Context) { height = prefs.getInt(K_H, 0), hz = prefs.getInt(K_HZ, 0), bitrateKbps = prefs.getInt(K_BITRATE, 0), + renderScale = prefs.getFloat(K_RENDER_SCALE, 1.0f).toDouble(), hdrEnabled = prefs.getBoolean(K_HDR, true), compositor = prefs.getInt(K_COMPOSITOR, 0), gamepad = prefs.getInt(K_GAMEPAD, 0), @@ -171,6 +180,7 @@ class SettingsStore(context: Context) { .putInt(K_H, s.height) .putInt(K_HZ, s.hz) .putInt(K_BITRATE, s.bitrateKbps) + .putFloat(K_RENDER_SCALE, s.renderScale.toFloat()) .putBoolean(K_HDR, s.hdrEnabled) .putInt(K_COMPOSITOR, s.compositor) .putInt(K_GAMEPAD, s.gamepad) @@ -193,6 +203,7 @@ class SettingsStore(context: Context) { const val K_H = "height" const val K_HZ = "hz" const val K_BITRATE = "bitrate_kbps" + const val K_RENDER_SCALE = "render_scale" const val K_HDR = "hdr_enabled" const val K_COMPOSITOR = "compositor" const val K_GAMEPAD = "gamepad" @@ -281,6 +292,54 @@ fun Settings.effectiveMode(context: Context): Triple { return Triple(w, h, hz) } +/** + * Client-side render-scale geometry — the Kotlin twin of `punktfunk-core`'s `render_scale` module + * (and the Apple client's `RenderScale`). Multiply a base size, preserve aspect, even-floor (the + * host rejects odd sizes), and clamp uniformly to the codec's per-axis ceiling so a connect can't + * ask for a size the encoder rejects. `1.0` = Native. Pure + covered by [RenderScaleTest]. + */ +object RenderScale { + val PRESETS = listOf(0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0) + + /** H.264 tops out at 4096 px/axis; HEVC/AV1/auto at 8192 — the host's `codec.rs` walls. */ + fun maxDimension(codec: String): Int = if (codec == "h264") 4096 else 8192 + + /** Clamp a raw multiplier into [0.5, 4.0]; a missing / non-positive / NaN value → 1.0. */ + fun sanitize(raw: Double): Double = if (raw > 0.0) raw.coerceIn(0.5, 4.0) else 1.0 + + /** "Native (1×)" / "1.5×" / "2× · supersample" — the picker label. */ + fun label(scale: Double): String = when { + scale == 1.0 -> "Native (1×)" + scale > 1.0 -> "${trim(scale)}× · supersample" + else -> "${trim(scale)}×" + } + + private fun trim(s: Double): String = + if (s == s.toLong().toDouble()) s.toLong().toString() else s.toString() + + /** Apply [scale] to a base size → a host-valid even, aspect-preserved, codec-clamped (w, h). */ + fun apply(baseW: Int, baseH: Int, scale: Double, maxDim: Int): Pair { + val s = sanitize(scale) + var w = maxOf(baseW, 1) * s + var h = maxOf(baseH, 1) * s + val cap = maxDim.toDouble() + val over = maxOf(w / cap, h / cap) + if (over > 1.0) { + w /= over + h /= over + } + return Pair(evenFloor(w, 320), evenFloor(h, 200)) + } + + private fun evenFloor(value: Double, minimum: Int): Int { + val v = maxOf(kotlin.math.floor(value).toInt(), minimum).coerceAtLeast(0) + return v / 2 * 2 + } +} + +/** (scale, label) for the render-scale picker. `1.0` = Native. */ +val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it) } + // ---- UI option tables (value, label). The first entry is always the "auto/native" default. ---- /** (width, height, label). `(0,0)` = native display. */ diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 5ff10bcd..cd84d31a 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -333,6 +333,15 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an update(s.copy(bitrateKbps = kbps)) } + SettingDropdown( + label = "Render scale", + options = RENDER_SCALE_OPTIONS, + // Snap the stored value (a Float round-tripped to Double) to the nearest preset so the + // exact Double keys match. > 1 supersamples for sharpness (more bandwidth AND decode); + // < 1 renders under native for a lighter host — this device resamples to the display. + selected = RenderScale.PRESETS.minByOrNull { kotlin.math.abs(it - s.renderScale) } ?: 1.0, + ) { scale -> update(s.copy(renderScale = scale)) } + // 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. diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/RenderScaleTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/RenderScaleTest.kt new file mode 100644 index 00000000..57448eb0 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/RenderScaleTest.kt @@ -0,0 +1,73 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure JVM test of the client-side render-scale geometry ([RenderScale]) — the Kotlin twin of + * `punktfunk-core`'s `render_scale` module. Run: `./gradlew :app:testDebugUnitTest`. + */ +class RenderScaleTest { + @Test + fun sanitizeClampsAndDefaults() { + assertEquals(1.0, RenderScale.sanitize(0.0), 0.0) // absent / zero → Native + assertEquals(1.0, RenderScale.sanitize(-2.0), 0.0) + assertEquals(1.0, RenderScale.sanitize(Double.NaN), 0.0) + assertEquals(0.5, RenderScale.sanitize(0.1), 0.0) // below the floor + assertEquals(4.0, RenderScale.sanitize(9.0), 0.0) // above the ceiling + assertEquals(1.5, RenderScale.sanitize(1.5), 0.0) + } + + @Test + fun maxDimensionIsCodecAware() { + assertEquals(4096, RenderScale.maxDimension("h264")) + assertEquals(8192, RenderScale.maxDimension("hevc")) + assertEquals(8192, RenderScale.maxDimension("av1")) + assertEquals(8192, RenderScale.maxDimension("auto")) + } + + @Test + fun nativeIsIdentity() { + assertEquals(1920 to 1080, RenderScale.apply(1920, 1080, 1.0, 8192)) + } + + @Test + fun supersampleDoubles() { + assertEquals(3840 to 2160, RenderScale.apply(1920, 1080, 2.0, 8192)) + } + + @Test + fun underRenderHalves() { + assertEquals(960 to 540, RenderScale.apply(1920, 1080, 0.5, 8192)) + } + + @Test + fun resultsAreEven() { + // 1366×768 × 1.5 = 2049×1152 → even-floored to 2048×1152. + val (w, h) = RenderScale.apply(1366, 768, 1.5, 8192) + assertEquals(0, w % 2) + assertEquals(0, h % 2) + assertEquals(2048 to 1152, w to h) + } + + @Test + fun overCeilingClampsUniformly() { + // 4K × 4 = 15360×8640; both exceed 8192 → width lands on cap, 16:9 kept (8192×4608). + val (w, h) = RenderScale.apply(3840, 2160, 4.0, 8192) + assertTrue(w <= 8192 && h <= 8192) + assertEquals(8192 to 4608, w to h) + } + + @Test + fun h264CeilingIsTighter() { + // 1080p × 4 = 7680×4320; under H.264's 4096 wall → 4096×2304. + assertEquals(4096 to 2304, RenderScale.apply(1920, 1080, 4.0, 4096)) + } + + @Test + fun minimumFloorHonoured() { + val (w, h) = RenderScale.apply(400, 300, 0.5, 8192) + assertTrue(w >= 320 && h >= 200) + } +} diff --git a/clients/decky/main.py b/clients/decky/main.py index 9e8f7cfc..f84fc160 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -896,8 +896,8 @@ class Plugin: except (OSError, json.JSONDecodeError): # The client's own defaults (native display, host-default bitrate, auto pad). return { - "width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0, - "codec": "auto", "gamepad": "auto", "compositor": "auto", + "width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0, + "bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto", "inhibit_shortcuts": True, "mic_enabled": False, } diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 2d63e2f1..8068e462 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -99,6 +99,7 @@ export interface StreamSettings { width: number; // 0 = native height: number; // 0 = native refresh_hz: number; // 0 = native + render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files) 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" diff --git a/clients/decky/src/settings.tsx b/clients/decky/src/settings.tsx index c81d79cd..9fa58279 100644 --- a/clients/decky/src/settings.tsx +++ b/clients/decky/src/settings.tsx @@ -25,6 +25,10 @@ const RESOLUTIONS: [number, number, string][] = [ [2560, 1440, "2560 × 1440"], ]; const REFRESH = [0, 30, 60, 90, 120]; +// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native. +const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; +const renderScaleLabel = (x: number): string => + x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`; const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"]; const GAMEPAD_LABELS: Record = { auto: "Automatic", @@ -106,6 +110,24 @@ export const SettingsSection: FC = () => { + + +
+ ({ data: x, label: renderScaleLabel(x) }))} + // Snap the stored value to the nearest preset so the dropdown always shows a match. + selectedOption={RENDER_SCALES.reduce((best, x) => + Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best, + )} + onChange={(o) => patch({ render_scale: o.data as number })} + /> +
+
+
String { + if scale == 1.0 { + "Native".to_string() + } else if scale > 1.0 { + format!("{scale}× (supersample)") + } else { + format!("{scale}×") + } +} const GAMEPADS: &[&str] = &[ "auto", "xbox360", @@ -304,6 +318,18 @@ pub fn show( "", &hz_names.iter().map(String::as_str).collect::>(), ); + let scale_names: Vec = RENDER_SCALES + .iter() + .map(|&s| render_scale_label(s)) + .collect(); + let scale_row = ChoiceRow::new( + &dialog, + inline, + "Render scale", + "Supersample for sharpness (> 1×, more bandwidth and decode) or render below native \ + (< 1×) for a lighter host — this device resamples to the window", + &scale_names.iter().map(String::as_str).collect::>(), + ); let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0); bitrate_row.set_title("Bitrate"); bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high"); @@ -346,6 +372,7 @@ pub fn show( .build(); stream.add(res_row.widget()); stream.add(hz_row.widget()); + stream.add(scale_row.widget()); stream.add(&bitrate_row); stream.add(compositor_row.widget()); stream.add(decoder_row.widget()); @@ -500,6 +527,11 @@ pub fn show( res_row.set_selected(res_i as u32); let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0); hz_row.set_selected(hz_i as u32); + let scale_i = RENDER_SCALES + .iter() + .position(|&x| (x - s.render_scale).abs() < 1e-6) + .unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap()); + scale_row.set_selected(scale_i as u32); bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0); pad_row.set_selected(pad_i as u32); @@ -545,6 +577,8 @@ pub fn show( RESOLUTIONS[res_i - 1] }; s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; + s.render_scale = + RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)]; s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string(); s.touch_mode = diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 9d587849..5f6b6b81 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -175,6 +175,8 @@ pub fn run(target: Option<&str>) -> u8 { // Latched at console start (like the stats tier above): toggling Match window in // the console's settings screen applies from the next console launch. match_window: crate::session_main::match_window(&settings_at_start), + render_scale: settings_at_start.render_scale, + render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings_at_start.codec), }; let result = diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index e0e211b3..d2855de7 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -127,6 +127,20 @@ mod session_main { settings.refresh_hz }, }; + // Render scale: multiply the resolved mode (even + codec-clamped) so the host renders + // larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the + // Native/explicit resolution so it composes uniformly with both. + let (sw, sh) = punktfunk_core::render_scale::apply( + mode.width, + mode.height, + settings.render_scale, + punktfunk_core::render_scale::max_dimension(&settings.codec), + ); + let mode = Mode { + width: sw, + height: sh, + ..mode + }; SessionParams { host: addr, port, @@ -372,6 +386,8 @@ mod session_main { overlay: None, window_size: window_size(&settings), match_window: match_window(&settings), + render_scale: settings.render_scale, + render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec), }; let outcome = diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index c4748a1d..a83210e8 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -19,6 +19,20 @@ const RESOLUTIONS: &[(u32, u32)] = &[ ]; /// `0` = the display's native refresh, resolved at connect. const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; +/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]). +/// `1.0` = Native. Applied at connect and each match-window resize. +const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; + +/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)". +fn render_scale_label(scale: f64) -> String { + if scale == 1.0 { + "Native".to_string() + } else if scale > 1.0 { + format!("{scale}\u{00D7} (supersample)") + } else { + format!("{scale}\u{00D7}") + } +} /// Decode backend presets: `(stored value, display label)`. // A stored legacy "hardware" (the D3D11VA era) matches no preset, so the combo shows // Automatic — which is exactly how the session's decoder chain reads that value. @@ -193,6 +207,24 @@ pub(crate) fn settings_page( s.refresh_hz = REFRESH[i]; }) .tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect."); + let (scale_names, scale_i) = { + let names: Vec = RENDER_SCALES + .iter() + .map(|&x| render_scale_label(x)) + .collect(); + let i = RENDER_SCALES + .iter() + .position(|&x| (x - s.render_scale).abs() < 1e-6) + .unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap()); + (names, i) + }; + let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| { + s.render_scale = RENDER_SCALES[i]; + }) + .tooltip( + "Supersample for sharpness (above 1\u{00D7}, more bandwidth and decode) or render below \ + native (below 1\u{00D7}) for a lighter host \u{2014} this device resamples to the window.", + ); let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor); let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| { s.compositor = COMPOSITORS[i].0.to_string(); @@ -441,6 +473,7 @@ pub(crate) fn settings_page( settings_card(vec![ res_combo.into(), hz_combo.into(), + scale_combo.into(), fullscreen_toggle.into(), comp_combo.into(), ]), diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 6df4ebbe..2d2c6f11 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -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(), diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 0568374f..d11d7576 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -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>, + /// 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 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 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 // --- 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 /// 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) => { diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index d5a0dc50..d98c5adf 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -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")] diff --git a/crates/punktfunk-core/src/render_scale.rs b/crates/punktfunk-core/src/render_scale.rs new file mode 100644 index 00000000..9b13af42 --- /dev/null +++ b/crates/punktfunk-core/src/render_scale.rs @@ -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); + } +}