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:
@@ -30,7 +30,13 @@ suspend fun connectToHost(
|
|||||||
): Long {
|
): Long {
|
||||||
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
|
// 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).
|
// 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)
|
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
||||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ data class Settings(
|
|||||||
val height: Int = 0,
|
val height: Int = 0,
|
||||||
val hz: Int = 0,
|
val hz: Int = 0,
|
||||||
val bitrateKbps: 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
|
* 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
|
* 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),
|
height = prefs.getInt(K_H, 0),
|
||||||
hz = prefs.getInt(K_HZ, 0),
|
hz = prefs.getInt(K_HZ, 0),
|
||||||
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
||||||
|
renderScale = prefs.getFloat(K_RENDER_SCALE, 1.0f).toDouble(),
|
||||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||||
@@ -171,6 +180,7 @@ class SettingsStore(context: Context) {
|
|||||||
.putInt(K_H, s.height)
|
.putInt(K_H, s.height)
|
||||||
.putInt(K_HZ, s.hz)
|
.putInt(K_HZ, s.hz)
|
||||||
.putInt(K_BITRATE, s.bitrateKbps)
|
.putInt(K_BITRATE, s.bitrateKbps)
|
||||||
|
.putFloat(K_RENDER_SCALE, s.renderScale.toFloat())
|
||||||
.putBoolean(K_HDR, s.hdrEnabled)
|
.putBoolean(K_HDR, s.hdrEnabled)
|
||||||
.putInt(K_COMPOSITOR, s.compositor)
|
.putInt(K_COMPOSITOR, s.compositor)
|
||||||
.putInt(K_GAMEPAD, s.gamepad)
|
.putInt(K_GAMEPAD, s.gamepad)
|
||||||
@@ -193,6 +203,7 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_H = "height"
|
const val K_H = "height"
|
||||||
const val K_HZ = "hz"
|
const val K_HZ = "hz"
|
||||||
const val K_BITRATE = "bitrate_kbps"
|
const val K_BITRATE = "bitrate_kbps"
|
||||||
|
const val K_RENDER_SCALE = "render_scale"
|
||||||
const val K_HDR = "hdr_enabled"
|
const val K_HDR = "hdr_enabled"
|
||||||
const val K_COMPOSITOR = "compositor"
|
const val K_COMPOSITOR = "compositor"
|
||||||
const val K_GAMEPAD = "gamepad"
|
const val K_GAMEPAD = "gamepad"
|
||||||
@@ -281,6 +292,54 @@ fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
|||||||
return Triple(w, h, hz)
|
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<Int, Int> {
|
||||||
|
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. ----
|
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
||||||
|
|
||||||
/** (width, height, label). `(0,0)` = native display. */
|
/** (width, height, label). `(0,0)` = native display. */
|
||||||
|
|||||||
@@ -333,6 +333,15 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
|||||||
update(s.copy(bitrateKbps = kbps))
|
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
|
// 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
|
// 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.
|
// device stays visible so the selection is always representable.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -896,8 +896,8 @@ class Plugin:
|
|||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
# 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, "render_scale": 1.0,
|
||||||
"codec": "auto", "gamepad": "auto", "compositor": "auto",
|
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ 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
|
||||||
|
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||||
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)
|
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"
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const RESOLUTIONS: [number, number, string][] = [
|
|||||||
[2560, 1440, "2560 × 1440"],
|
[2560, 1440, "2560 × 1440"],
|
||||||
];
|
];
|
||||||
const REFRESH = [0, 30, 60, 90, 120];
|
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 GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
||||||
const GAMEPAD_LABELS: Record<string, string> = {
|
const GAMEPAD_LABELS: Record<string, string> = {
|
||||||
auto: "Automatic",
|
auto: "Automatic",
|
||||||
@@ -106,6 +110,24 @@ export const SettingsSection: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</RowActions>
|
</RowActions>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Render scale"
|
||||||
|
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
|
||||||
|
childrenContainerWidth="max"
|
||||||
|
>
|
||||||
|
<RowActions>
|
||||||
|
<div style={selectShell}>
|
||||||
|
<Dropdown
|
||||||
|
rgOptions={RENDER_SCALES.map((x) => ({ 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 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</RowActions>
|
||||||
|
</Field>
|
||||||
<SliderField
|
<SliderField
|
||||||
label="Bitrate"
|
label="Bitrate"
|
||||||
description="Mbit/s · 0 = host default"
|
description="Mbit/s · 0 = host default"
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
|||||||
];
|
];
|
||||||
/// `0` = the monitor's native refresh, resolved at connect.
|
/// `0` = the monitor's native refresh, resolved at connect.
|
||||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
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}× (supersample)")
|
||||||
|
} else {
|
||||||
|
format!("{scale}×")
|
||||||
|
}
|
||||||
|
}
|
||||||
const GAMEPADS: &[&str] = &[
|
const GAMEPADS: &[&str] = &[
|
||||||
"auto",
|
"auto",
|
||||||
"xbox360",
|
"xbox360",
|
||||||
@@ -304,6 +318,18 @@ pub fn show(
|
|||||||
"",
|
"",
|
||||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
|
let scale_names: Vec<String> = 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::<Vec<_>>(),
|
||||||
|
);
|
||||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||||
bitrate_row.set_title("Bitrate");
|
bitrate_row.set_title("Bitrate");
|
||||||
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||||||
@@ -346,6 +372,7 @@ pub fn show(
|
|||||||
.build();
|
.build();
|
||||||
stream.add(res_row.widget());
|
stream.add(res_row.widget());
|
||||||
stream.add(hz_row.widget());
|
stream.add(hz_row.widget());
|
||||||
|
stream.add(scale_row.widget());
|
||||||
stream.add(&bitrate_row);
|
stream.add(&bitrate_row);
|
||||||
stream.add(compositor_row.widget());
|
stream.add(compositor_row.widget());
|
||||||
stream.add(decoder_row.widget());
|
stream.add(decoder_row.widget());
|
||||||
@@ -500,6 +527,11 @@ pub fn show(
|
|||||||
res_row.set_selected(res_i as u32);
|
res_row.set_selected(res_i as u32);
|
||||||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||||
hz_row.set_selected(hz_i as u32);
|
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);
|
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||||
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
||||||
pad_row.set_selected(pad_i as u32);
|
pad_row.set_selected(pad_i as u32);
|
||||||
@@ -545,6 +577,8 @@ pub fn show(
|
|||||||
RESOLUTIONS[res_i - 1]
|
RESOLUTIONS[res_i - 1]
|
||||||
};
|
};
|
||||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 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.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||||
s.touch_mode =
|
s.touch_mode =
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
// Latched at console start (like the stats tier above): toggling Match window in
|
// Latched at console start (like the stats tier above): toggling Match window in
|
||||||
// the console's settings screen applies from the next console launch.
|
// the console's settings screen applies from the next console launch.
|
||||||
match_window: crate::session_main::match_window(&settings_at_start),
|
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 =
|
let result =
|
||||||
|
|||||||
@@ -127,6 +127,20 @@ mod session_main {
|
|||||||
settings.refresh_hz
|
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 {
|
SessionParams {
|
||||||
host: addr,
|
host: addr,
|
||||||
port,
|
port,
|
||||||
@@ -372,6 +386,8 @@ mod session_main {
|
|||||||
overlay: None,
|
overlay: None,
|
||||||
window_size: window_size(&settings),
|
window_size: window_size(&settings),
|
||||||
match_window: match_window(&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 =
|
let outcome =
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
|||||||
];
|
];
|
||||||
/// `0` = the display's native refresh, resolved at connect.
|
/// `0` = the display's native refresh, resolved at connect.
|
||||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
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)`.
|
/// Decode backend presets: `(stored value, display label)`.
|
||||||
// A stored legacy "hardware" (the D3D11VA era) matches no preset, so the combo shows
|
// 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.
|
// 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];
|
s.refresh_hz = REFRESH[i];
|
||||||
})
|
})
|
||||||
.tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect.");
|
.tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect.");
|
||||||
|
let (scale_names, scale_i) = {
|
||||||
|
let names: Vec<String> = 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_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
||||||
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
||||||
s.compositor = COMPOSITORS[i].0.to_string();
|
s.compositor = COMPOSITORS[i].0.to_string();
|
||||||
@@ -441,6 +473,7 @@ pub(crate) fn settings_page(
|
|||||||
settings_card(vec![
|
settings_card(vec![
|
||||||
res_combo.into(),
|
res_combo.into(),
|
||||||
hz_combo.into(),
|
hz_combo.into(),
|
||||||
|
scale_combo.into(),
|
||||||
fullscreen_toggle.into(),
|
fullscreen_toggle.into(),
|
||||||
comp_combo.into(),
|
comp_combo.into(),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -461,6 +461,14 @@ pub struct Settings {
|
|||||||
pub refresh_hz: u32,
|
pub refresh_hz: u32,
|
||||||
/// Requested encoder bitrate (kbps); 0 = host default.
|
/// Requested encoder bitrate (kbps); 0 = host default.
|
||||||
pub bitrate_kbps: u32,
|
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,
|
pub gamepad: String,
|
||||||
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
|
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
|
||||||
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
||||||
@@ -590,6 +598,7 @@ impl Default for Settings {
|
|||||||
height: 0,
|
height: 0,
|
||||||
refresh_hz: 0,
|
refresh_hz: 0,
|
||||||
bitrate_kbps: 0,
|
bitrate_kbps: 0,
|
||||||
|
render_scale: 1.0,
|
||||||
gamepad: "auto".into(),
|
gamepad: "auto".into(),
|
||||||
forward_pad: String::new(),
|
forward_pad: String::new(),
|
||||||
compositor: "auto".into(),
|
compositor: "auto".into(),
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ pub struct SessionOpts {
|
|||||||
/// persists it for the next launch. `None` = never auto-resize (Auto-native /
|
/// persists it for the next launch. `None` = never auto-resize (Auto-native /
|
||||||
/// Explicit keep today's behavior).
|
/// Explicit keep today's behavior).
|
||||||
pub match_window: Option<Box<dyn FnMut(u32, u32)>>,
|
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 {
|
pub enum Outcome {
|
||||||
@@ -411,7 +418,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
presenter.vulkan_decode(),
|
presenter.vulkan_decode(),
|
||||||
);
|
);
|
||||||
if opts.match_window.is_some() {
|
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(
|
Some(StreamState::new(
|
||||||
params,
|
params,
|
||||||
@@ -749,7 +761,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
ActionOutcome::Handled => {}
|
ActionOutcome::Handled => {}
|
||||||
ActionOutcome::Start(mut params) => {
|
ActionOutcome::Start(mut params) => {
|
||||||
if opts.match_window.is_some() {
|
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(
|
stream = Some(StreamState::new(
|
||||||
*params,
|
*params,
|
||||||
@@ -896,7 +913,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
// --- Match-window (D2): debounced mode-follow ----
|
// --- Match-window (D2): debounced mode-follow ----
|
||||||
if let Some(persist) = opts.match_window.as_mut() {
|
if let Some(persist) = opts.match_window.as_mut() {
|
||||||
if let Some(st) = stream.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
|
// 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
|
/// 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
|
/// sane minimum — keeping the resolved refresh. Under `--fullscreen` the window IS the
|
||||||
/// display, so this degenerates to the display's native mode.
|
/// 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();
|
let (pw, ph) = window.size_in_pixels();
|
||||||
params.mode.width = (pw & !1).max(320);
|
// × the render scale (even + codec-clamped), so match-window supersamples/undersamples exactly
|
||||||
params.mode.height = (ph & !1).max(200);
|
// 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!(
|
tracing::info!(
|
||||||
w = params.mode.width,
|
w,
|
||||||
h = params.mode.height,
|
h,
|
||||||
"match-window: requesting the window's pixel size"
|
"match-window: requesting the scaled window pixel size"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1250,18 +1281,25 @@ fn resize_tick(
|
|||||||
st: &mut StreamState,
|
st: &mut StreamState,
|
||||||
window: &mut sdl3::video::Window,
|
window: &mut sdl3::video::Window,
|
||||||
persist: &mut dyn FnMut(u32, u32),
|
persist: &mut dyn FnMut(u32, u32),
|
||||||
|
render_scale: f64,
|
||||||
|
max_dim: u32,
|
||||||
) {
|
) {
|
||||||
let Some(c) = &st.connector else {
|
let Some(c) = &st.connector else {
|
||||||
return; // not connected yet — the pending stamp survives until we are
|
return; // not connected yet — the pending stamp survives until we are
|
||||||
};
|
};
|
||||||
let m = c.mode();
|
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(
|
match resize_decision(
|
||||||
Instant::now(),
|
Instant::now(),
|
||||||
&mut st.resize_pending,
|
&mut st.resize_pending,
|
||||||
st.resize_sent_at,
|
st.resize_sent_at,
|
||||||
st.resize_requested,
|
st.resize_requested,
|
||||||
(m.width, m.height),
|
(m.width, m.height),
|
||||||
window.size_in_pixels(),
|
pixel_size,
|
||||||
) {
|
) {
|
||||||
ResizeAction::Wait => {}
|
ResizeAction::Wait => {}
|
||||||
ResizeAction::Settled(target) => {
|
ResizeAction::Settled(target) => {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub mod packet;
|
|||||||
pub mod quic;
|
pub mod quic;
|
||||||
pub mod reanchor;
|
pub mod reanchor;
|
||||||
pub mod reject;
|
pub mod reject;
|
||||||
|
pub mod render_scale;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
#[cfg(feature = "tls")]
|
#[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