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 {
|
||||
// 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)
|
||||
|
||||
@@ -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<Int, Int, Int> {
|
||||
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. ----
|
||||
|
||||
/** (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))
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user