From 419463e1585aa1efddeb84a541db469d0fbffd62 Mon Sep 17 00:00:00 2001 From: FacuM Date: Tue, 27 Oct 2026 00:30:02 -0300 Subject: [PATCH 1/5] Raise first-frame quality and report the negotiated stream The encoders ran CBR with a two-frame VBV, which capped the first IDR near twice the mean frame size (about 30 dB PSNR on a desktop at 46 Mbit/s); with an infinite GOP and damage-driven capture that blocky frame stayed on screen. Encoders now use quality-targeted VBR (NVENC cq 19, VAAPI VBR or CQP qp 20, libx264 crf 20) with a 2x peak and a four-frame VBV at the peak rate, giving about 50 dB on the first frame and far smaller frames on a still screen. Protocol 0.7 adds Control/18 StreamReport: the wanted bitrate, whether it came from settings, the link ceiling, every encoder and option set the host tried with its error, and the rate control that opened. The Android app shows it for a few seconds as an on-screen display in the bottom-left corner, with the codec and final rate in a larger line. --- .../com/librestatic/ezdisplay/MainActivity.kt | 27 +++ .../com/librestatic/ezdisplay/StreamOsd.kt | 195 ++++++++++++++++++ .../app/src/main/res/values-de/strings.xml | 15 ++ .../app/src/main/res/values-es/strings.xml | 16 ++ .../app/src/main/res/values-fr/strings.xml | 16 ++ .../app/src/main/res/values-it/strings.xml | 16 ++ .../app/src/main/res/values-pt/strings.xml | 16 ++ android/app/src/main/res/values/strings.xml | 15 ++ .../librestatic/ezdisplay/StreamOsdTest.kt | 124 +++++++++++ crates/ezd-android/src/session.rs | 7 +- crates/ezd-android/src/status.rs | 96 ++++++++- crates/ezd-client-core/src/session.rs | 10 +- crates/ezd-fake-client/src/lib.rs | 6 +- crates/ezd-ffmpeg/src/encoder.rs | 10 +- crates/ezd-ffmpeg/src/lib.rs | 5 + crates/ezd-ffmpeg/src/options.rs | 45 +++- crates/ezd-ffmpeg/src/rate.rs | 145 +++++++++++++ crates/ezd-host-core/src/lib.rs | 4 +- crates/ezd-host-core/src/session.rs | 160 +++++++++++++- crates/ezd-host-core/src/source.rs | 20 +- crates/ezd-host-linux/src/encode.rs | 75 ++++++- crates/ezd-host-linux/src/source.rs | 42 +++- crates/ezd-host-windows/src/win/ffenc.rs | 9 +- crates/ezd-proto/src/lib.rs | 7 +- crates/ezd-proto/src/msg.rs | 175 +++++++++++++++- crates/ezd-proto/src/negotiate.rs | 26 ++- docs/protocol.md | 40 +++- 27 files changed, 1261 insertions(+), 61 deletions(-) create mode 100644 android/app/src/main/kotlin/com/librestatic/ezdisplay/StreamOsd.kt create mode 100644 android/app/src/test/kotlin/com/librestatic/ezdisplay/StreamOsdTest.kt create mode 100644 crates/ezd-ffmpeg/src/rate.rs diff --git a/android/app/src/main/kotlin/com/librestatic/ezdisplay/MainActivity.kt b/android/app/src/main/kotlin/com/librestatic/ezdisplay/MainActivity.kt index f4d49a3..918da96 100644 --- a/android/app/src/main/kotlin/com/librestatic/ezdisplay/MainActivity.kt +++ b/android/app/src/main/kotlin/com/librestatic/ezdisplay/MainActivity.kt @@ -128,6 +128,9 @@ class MainActivity : Activity(), SurfaceHolder.Callback, Choreographer.FrameCall private var floatBuf = FloatArray(512) private var timeBuf = LongArray(64) private var statusHideAt = 0L + private lateinit var osdView: StreamOsdView + private var osdHideAt = 0L + private var osdSeq = -1 private var cursorSeq = 0L private var cursorShapeId = -1 @@ -232,6 +235,13 @@ class MainActivity : Activity(), SurfaceHolder.Callback, Choreographer.FrameCall root.addView(telemetryView, FrameLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.START, )) + osdView = StreamOsdView(this) + root.addView(osdView, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START, + ).apply { + val m = (16 * resources.displayMetrics.density).toInt() + setMargins(m, m, m, m) + }) settingsButton = ImageButton(this).apply { setImageResource(android.R.drawable.ic_menu_preferences) setBackgroundColor(0x99000000.toInt()) @@ -695,6 +705,7 @@ class MainActivity : Activity(), SurfaceHolder.Callback, Choreographer.FrameCall } val wifiChoice = activeWifi val usb = activeAccessory + if (j.optString("phase") != "streaming") hideOsd(false) val addr = if (wifiChoice != null) hostLabel(wifiChoice) else "$HOST:$PORT" when (j.optString("phase")) { "connecting" -> setStatus( @@ -712,6 +723,16 @@ class MainActivity : Activity(), SurfaceHolder.Callback, Choreographer.FrameCall }, ) "streaming" -> { + // Shown at stream start and again whenever the host sends a new report. + val info = StreamOsd.parse(j) + if (!wasStreaming || info.seq != osdSeq) { + osdSeq = info.seq + osdView.show(StreamOsd.render(resources, info)) + osdHideAt = now + StreamOsd.SHOW_MS + } else if (osdHideAt != 0L && now >= osdHideAt) { + osdHideAt = 0 + osdView.hide(true) + } if (!wasStreaming) { wasStreaming = true pairingLost = null @@ -779,6 +800,12 @@ class MainActivity : Activity(), SurfaceHolder.Callback, Choreographer.FrameCall } } + private fun hideOsd(animated: Boolean) { + osdHideAt = 0 + osdSeq = -1 + osdView.hide(animated) + } + private fun showTransient(text: String, ms: Long) { setStatus(text) statusHideAt = SystemClock.uptimeMillis() + ms diff --git a/android/app/src/main/kotlin/com/librestatic/ezdisplay/StreamOsd.kt b/android/app/src/main/kotlin/com/librestatic/ezdisplay/StreamOsd.kt new file mode 100644 index 0000000..d4e3b98 --- /dev/null +++ b/android/app/src/main/kotlin/com/librestatic/ezdisplay/StreamOsd.kt @@ -0,0 +1,195 @@ +package com.librestatic.ezdisplay + +import android.content.Context +import android.content.res.Resources +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.text.TextUtils +import android.util.TypedValue +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import org.json.JSONObject +import java.util.Locale + +/** One encoder the host tried to open. Encoder, options and error come from the host and are not translated. */ +data class OsdAttempt(val encoder: String, val options: String, val ok: Boolean, val error: String) + +/** The host's negotiation report (status JSON `report`). */ +data class OsdReport( + val wantedKbps: Int, + val userBitrate: Boolean, + val ceilingKbps: Int, + val attempts: List, + val rateMode: String, + val quality: Int, + val targetKbps: Int, + val maxKbps: Int, +) + +/** What the on-screen display needs from one status poll. */ +data class OsdInfo( + val seq: Int, + val codec: String, + val bitrateKbps: Int, + val width: Int, + val height: Int, + val refreshMhz: Int, + val report: OsdReport?, +) + +/** Text of the display: small monospace [lines] and the larger [headline]. */ +data class OsdContent(val lines: List, val headline: String) + +object StreamOsd { + const val MAX_ATTEMPTS = 6 + const val SHOW_MS = 6000L + const val FADE_MS = 400L + private const val MAX_ERROR_CHARS = 60 + + /** Reads the fields of the status JSON. A missing `report_seq` or `report` (older native lib or host) is none. */ + fun parse(j: JSONObject): OsdInfo { + val r = j.optJSONObject("report") + val report = r?.let { + val list = it.optJSONArray("attempts") + OsdReport( + wantedKbps = it.optInt("wanted_kbps"), + userBitrate = it.optBoolean("user_bitrate"), + ceilingKbps = it.optInt("ceiling_kbps"), + attempts = (0 until (list?.length() ?: 0)).mapNotNull { i -> list?.optJSONObject(i) }.map { a -> + OsdAttempt(a.optString("encoder"), a.optString("options"), a.optBoolean("ok"), a.optString("error")) + }, + rateMode = it.optString("rate_mode", "unknown"), + quality = it.optInt("quality"), + targetKbps = it.optInt("target_kbps"), + maxKbps = it.optInt("max_kbps"), + ) + } + return OsdInfo( + seq = j.optInt("report_seq"), + codec = j.optString("codec"), + bitrateKbps = j.optInt("bitrate_kbps"), + width = j.optInt("width"), + height = j.optInt("height"), + refreshMhz = j.optInt("refresh_mhz"), + report = report, + ) + } + + fun codecName(codec: String): String = when (codec.lowercase(Locale.ROOT)) { + "h264" -> "H.264" + "hevc" -> "HEVC" + "av1" -> "AV1" + else -> codec.uppercase(Locale.ROOT) + } + + /** "46 Mbit/s": one decimal below 10, none from 10 up. */ + fun mbit(res: Resources, kbps: Int): String = res.getString(R.string.osd_mbit, mbitNumber(res, kbps)) + + private fun locale(res: Resources): Locale = res.configuration.locales[0] + + private fun mbitNumber(res: Resources, kbps: Int): String { + val mbit = kbps / 1000.0 + return if (mbit < 10) String.format(locale(res), "%.1f", mbit) else String.format(locale(res), "%d", Math.round(mbit)) + } + + private fun hz(res: Resources, mhz: Int): String = + if (mhz % 1000 == 0) (mhz / 1000).toString() else String.format(locale(res), "%.2f", mhz / 1000.0) + + private fun ellipsize(s: String): String = + if (s.length <= MAX_ERROR_CHARS) s else s.take(MAX_ERROR_CHARS - 1).trimEnd() + "…" + + fun render(res: Resources, info: OsdInfo): OsdContent { + val report = info.report + val lines = mutableListOf() + if (report != null) { + val parts = mutableListOf(res.getString(R.string.osd_negotiated, mbit(res, info.bitrateKbps))) + parts += if (report.userBitrate) { + res.getString(R.string.osd_set_in_settings) + } else { + res.getString(R.string.osd_auto_for, "${info.width}×${info.height}", hz(res, info.refreshMhz)) + } + if (report.wantedKbps > report.ceilingKbps && report.ceilingKbps > 0) { + parts += res.getString( + R.string.osd_clamped, mbitNumber(res, report.wantedKbps), mbitNumber(res, report.ceilingKbps), + ) + } + if (report.ceilingKbps > 0) parts += res.getString(R.string.osd_link_limit, mbit(res, report.ceilingKbps)) + lines += parts.joinToString(" · ") + val hidden = (report.attempts.size - MAX_ATTEMPTS).coerceAtLeast(0) + if (hidden > 0) lines += res.getQuantityString(R.plurals.osd_more_attempts, hidden, hidden) + for (a in report.attempts.drop(hidden)) { + val head = "${if (a.ok) "✓" else "✗"} ${a.encoder}" + if (a.options.isNotEmpty()) " ${a.options}" else "" + lines += if (!a.ok && a.error.isNotEmpty()) "$head — ${ellipsize(a.error.lineSequence().first())}" else head + } + } + val headline = mutableListOf(codecName(info.codec)) + if (report != null) { + when (report.rateMode) { + "cbr" -> res.getString(R.string.osd_rate_cbr) + "vbr" -> res.getString(R.string.osd_rate_vbr, report.quality) + "cqp" -> res.getString(R.string.osd_rate_cqp, report.quality) + "crf" -> res.getString(R.string.osd_rate_crf, report.quality) + else -> null + }?.let { headline += it } + } + headline += if (report != null && report.maxKbps > info.bitrateKbps && info.bitrateKbps > 0) { + res.getString(R.string.osd_rate_peak, mbitNumber(res, info.bitrateKbps), mbitNumber(res, report.maxKbps)) + } else { + mbit(res, info.bitrateKbps) + } + return OsdContent(lines, headline.joinToString(" · ")) + } +} + +/** Small, non-interactive overlay for the bottom-left corner. */ +class StreamOsdView(context: Context) : LinearLayout(context) { + private val detail = TextView(context).apply { + setTextColor(Color.WHITE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f) + typeface = Typeface.MONOSPACE + maxLines = 8 + ellipsize = TextUtils.TruncateAt.END + } + private val headline = TextView(context).apply { + setTextColor(Color.WHITE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 19f) + typeface = Typeface.DEFAULT_BOLD + } + + init { + orientation = VERTICAL + val pad = (10 * resources.displayMetrics.density).toInt() + setPadding(pad, pad, pad, pad) + background = GradientDrawable().apply { + setColor(0x99000000.toInt()) + cornerRadius = 8 * resources.displayMetrics.density + } + isClickable = false + isFocusable = false + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + addView(detail, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + addView(headline, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + visibility = GONE + } + + fun show(content: OsdContent) { + detail.text = content.lines.joinToString("\n") + detail.visibility = if (content.lines.isEmpty()) GONE else VISIBLE + headline.text = content.headline + animate().cancel() + alpha = 1f + visibility = VISIBLE + } + + fun hide(animated: Boolean) { + if (visibility != VISIBLE) return + if (animated) { + animate().alpha(0f).setDuration(StreamOsd.FADE_MS).withEndAction { visibility = GONE } + } else { + animate().cancel() + visibility = GONE + } + } +} diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 0f40f85..1de13cd 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -152,4 +152,19 @@ %1$d gekoppelter Host %1$d gekoppelte Hosts + Ausgehandelt: %1$s + automatisch für %1$s bei %2$s Hz + in den Einstellungen festgelegt + gewünscht %1$s, begrenzt auf %2$s + Verbindungslimit %1$s + %1$s Mbit/s + %1$s Mbit/s (Spitze %2$s) + konstante Bitrate + variable Bitrate, Qualität %1$d + konstante Qualität %1$d + konstanter Ratenfaktor %1$d + + +%1$d weiteres + +%1$d weitere + diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 509bbc3..b6ebeee 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -154,4 +154,20 @@ %1$d hosts vinculados %1$d hosts vinculados + Negociado: %1$s + auto para %1$s a %2$s Hz + definido en ajustes + pedido %1$s, limitado a %2$s + límite del enlace %1$s + %1$s Mbit/s + %1$s Mbit/s (pico %2$s) + tasa de bits constante + tasa de bits variable, calidad %1$d + calidad constante %1$d + factor de tasa constante %1$d + + +%1$d más + +%1$d más + +%1$d más + diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 8d204b5..a843b8e 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -154,4 +154,20 @@ %1$d hôtes associés %1$d hôtes associés + Négocié : %1$s + auto pour %1$s à %2$s Hz + défini dans les réglages + demandé %1$s, limité à %2$s + limite de la liaison %1$s + %1$s Mbit/s + %1$s Mbit/s (crête %2$s) + débit constant + débit variable, qualité %1$d + qualité constante %1$d + facteur de débit constant %1$d + + +%1$d de plus + +%1$d de plus + +%1$d de plus + diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 323d90f..01e2150 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -154,4 +154,20 @@ %1$d host associati %1$d host associati + Negoziato: %1$s + auto per %1$s a %2$s Hz + impostato nelle impostazioni + richiesto %1$s, limitato a %2$s + limite del collegamento %1$s + %1$s Mbit/s + %1$s Mbit/s (picco %2$s) + bitrate costante + bitrate variabile, qualità %1$d + qualità costante %1$d + fattore di velocità costante %1$d + + +%1$d altro + +%1$d altri + +%1$d altri + diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml index 66f39d7..93ffbb4 100644 --- a/android/app/src/main/res/values-pt/strings.xml +++ b/android/app/src/main/res/values-pt/strings.xml @@ -154,4 +154,20 @@ %1$d hosts pareados %1$d hosts pareados + Negociado: %1$s + automático para %1$s a %2$s Hz + definido nas configurações + desejado %1$s, limitado a %2$s + limite do link %1$s + %1$s Mbit/s + %1$s Mbit/s (pico %2$s) + taxa de bits constante + taxa de bits variável, qualidade %1$d + qualidade constante %1$d + fator de taxa constante %1$d + + +%1$d a mais + +%1$d a mais + +%1$d a mais + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6a4fd8c..00fc2c3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -152,4 +152,19 @@ %1$d paired host %1$d paired hosts + Negotiated %1$s + auto for %1$s at %2$s Hz + set in settings + wanted %1$s, limited to %2$s + link limit %1$s + %1$s Mbit/s + %1$s Mbit/s (peak %2$s) + constant bitrate + variable bitrate, quality %1$d + constant quality %1$d + constant rate factor %1$d + + +%1$d more + +%1$d more + diff --git a/android/app/src/test/kotlin/com/librestatic/ezdisplay/StreamOsdTest.kt b/android/app/src/test/kotlin/com/librestatic/ezdisplay/StreamOsdTest.kt new file mode 100644 index 0000000..0d4c436 --- /dev/null +++ b/android/app/src/test/kotlin/com/librestatic/ezdisplay/StreamOsdTest.kt @@ -0,0 +1,124 @@ +package com.librestatic.ezdisplay + +import androidx.activity.ComponentActivity +import android.graphics.Color +import android.view.Gravity +import android.view.View +import android.widget.FrameLayout +import androidx.test.core.app.ApplicationProvider +import com.github.takahirom.roborazzi.captureRoboImage +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class StreamOsdTest { + private val context get() = ApplicationProvider.getApplicationContext() + private val res get() = context.resources + + private fun status(report: String, extra: String = "") = JSONObject( + """{"codec":"h264","bitrate_kbps":46000,"width":2960,"height":1848,"refresh_mhz":120000, + "report_seq":2,"report":$report$extra}""", + ) + + private val autoReport = """{"wanted_kbps":46000,"user_bitrate":false,"ceiling_kbps":80000, + "attempts":[{"encoder":"h264_vaapi","options":"rc_mode=VBR low_power=1","ok":false,"error":"Invalid argument"}, + {"encoder":"h264_nvenc","options":"rc=vbr cq=19","ok":true,"error":""}], + "rate_mode":"vbr","quality":19,"target_kbps":46000,"max_kbps":92000}""" + + @Test fun reportWithAttemptsAndPeak() { + val c = StreamOsd.render(res, StreamOsd.parse(status(autoReport))) + assertEquals("Negotiated 46 Mbit/s · auto for 2960×1848 at 120 Hz · link limit 80 Mbit/s", c.lines[0]) + assertEquals("✗ h264_vaapi rc_mode=VBR low_power=1 — Invalid argument", c.lines[1]) + assertEquals("✓ h264_nvenc rc=vbr cq=19", c.lines[2]) + assertEquals("H.264 · variable bitrate, quality 19 · 46 Mbit/s (peak 92)", c.headline) + } + + @Test fun clampedBitrateShowsWantedAndLimit() { + val r = """{"wanted_kbps":95000,"user_bitrate":false,"ceiling_kbps":80000,"attempts":[], + "rate_mode":"cbr","quality":0,"target_kbps":80000,"max_kbps":80000}""" + val j = status(r).put("bitrate_kbps", 80000) + val c = StreamOsd.render(res, StreamOsd.parse(j)) + assertTrue(c.lines[0], c.lines[0].contains("wanted 95, limited to 80")) + assertEquals("H.264 · constant bitrate · 80 Mbit/s", c.headline) + } + + @Test fun userBitrateSaysSetInSettings() { + val r = autoReport.replace("\"user_bitrate\":false", "\"user_bitrate\":true") + val c = StreamOsd.render(res, StreamOsd.parse(status(r))) + assertTrue(c.lines[0], c.lines[0].contains("set in settings")) + assertFalse(c.lines[0].contains("auto for")) + } + + @Test fun smallBitrateHasOneDecimal() { + assertEquals("4.5 Mbit/s", StreamOsd.mbit(res, 4500)) + assertEquals("46 Mbit/s", StreamOsd.mbit(res, 46000)) + } + + @Test fun longErrorIsEllipsizedOnOneLine() { + val r = autoReport.replace("Invalid argument", "x".repeat(200) + "\\nsecond") + val line = StreamOsd.render(res, StreamOsd.parse(status(r))).lines[1] + assertTrue(line, line.endsWith("…")) + assertFalse(line.contains("\n")) + assertTrue(line.length < 120) + } + + @Test fun attemptListIsCappedWithMoreLine() { + val attempts = (1..9).joinToString(",") { """{"encoder":"e$it","options":"","ok":false,"error":""}""" } + val r = """{"wanted_kbps":1,"user_bitrate":false,"ceiling_kbps":2,"attempts":[$attempts], + "rate_mode":"unknown","quality":0,"target_kbps":1,"max_kbps":1}""" + val c = StreamOsd.render(res, StreamOsd.parse(status(r))) + assertEquals(1 + 1 + StreamOsd.MAX_ATTEMPTS, c.lines.size) + assertEquals("+3 more", c.lines[1]) + assertEquals("✗ e4", c.lines[2]) + assertEquals("✗ e9", c.lines.last()) + } + + @Test fun oldHostShowsOnlyCodecAndBitrate() { + val j = JSONObject("""{"codec":"hevc","bitrate_kbps":30000,"width":1,"height":1,"refresh_mhz":60000}""") + val info = StreamOsd.parse(j) + assertEquals(0, info.seq) + assertNull(info.report) + val c = StreamOsd.render(res, info) + assertTrue(c.lines.isEmpty()) + assertEquals("HEVC · 30 Mbit/s", c.headline) + } + + @Test fun unknownRateModeIsOmitted() { + val r = autoReport.replace("\"vbr\"", "\"unknown\"").replace("92000", "46000") + assertEquals("H.264 · 46 Mbit/s", StreamOsd.render(res, StreamOsd.parse(status(r))).headline) + } + + @Test @Config(qualifiers = "es") fun spanishResolves() { + val c = StreamOsd.render(res, StreamOsd.parse(status(autoReport))) + assertTrue(c.lines[0], c.lines[0].startsWith("Negociado: 46 Mbit/s")) + assertTrue(c.headline, c.headline.contains("tasa de bits variable, calidad 19")) + } + + @Test fun viewIsNotInteractive() { + val v = StreamOsdView(context) + assertFalse(v.isClickable) + assertFalse(v.isFocusable) + } + + @Test @Config(qualifiers = "w1280dp-h800dp-notnight") + fun captureOsd() { + val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get() + val root = FrameLayout(activity).apply { setBackgroundColor(Color.rgb(40, 60, 90)) } + val osd = StreamOsdView(activity) + root.addView(osd, FrameLayout.LayoutParams(-2, -2, Gravity.BOTTOM or Gravity.START).apply { setMargins(32, 32, 32, 32) }) + activity.setContentView(root) + osd.show(StreamOsd.render(activity.resources, StreamOsd.parse(status(autoReport)))) + assertEquals(View.VISIBLE, osd.visibility) + root.captureRoboImage("build/outputs/roborazzi/stream_osd.png") + } +} diff --git a/crates/ezd-android/src/session.rs b/crates/ezd-android/src/session.rs index 989ea28..4f83180 100644 --- a/crates/ezd-android/src/session.rs +++ b/crates/ezd-android/src/session.rs @@ -11,7 +11,7 @@ use ezd_client_core::{ }; use ezd_link::{BlockingOptions, Link}; use ezd_proto::pair::Fingerprint; -use ezd_proto::{CursorShape, StreamColor, StreamConfig, VideoFrame}; +use ezd_proto::{CursorShape, StreamColor, StreamConfig, StreamReport, VideoFrame}; use std::fs::File; use std::io::{Read, Write}; use std::net::SocketAddr; @@ -190,6 +190,11 @@ impl ClientHandler for Handler { self.color = *color; } + fn on_stream_report(&mut self, report: &StreamReport) { + tracing::info!(?report, "host stream report"); + self.shared.set_report(report.clone()); + } + fn on_stream_config(&mut self, cfg: &StreamConfig) -> HandlerResult { tracing::info!( "stream {}x{} @ {:.3} Hz {:?} {} kbps ({})", diff --git a/crates/ezd-android/src/status.rs b/crates/ezd-android/src/status.rs index 0416acb..36460d4 100644 --- a/crates/ezd-android/src/status.rs +++ b/crates/ezd-android/src/status.rs @@ -1,7 +1,7 @@ //! Session phase and the JSON returned by `pollStats()`. use ezd_client_core::ClientSnapshot; -use ezd_proto::{Codec, StreamConfig}; +use ezd_proto::{Codec, StreamConfig, StreamReport}; use serde::Serialize; use std::sync::Mutex; @@ -33,6 +33,10 @@ struct State { decoder: Option, /// Size of the decoder's output buffers, which can exceed the stream (coded vs cropped size). buffer: Option<(i32, i32)>, + /// How the host negotiated and encodes the current stream, once it sent the report. + report: Option, + /// Reports stored so far in this process (0 = none yet). + report_seq: u64, } /// State shared between the client task, the decoder thread and the JNI poll calls. @@ -47,6 +51,8 @@ impl Default for Shared { stream: None, decoder: None, buffer: None, + report: None, + report_seq: 0, })) } } @@ -82,7 +88,19 @@ impl Shared { /// Records the negotiated stream. pub fn set_stream(&self, stream: StreamConfig) { - self.with(|s| s.stream = Some(stream)); + self.with(|s| { + s.stream = Some(stream); + // The report of the previous stream no longer applies. + s.report = None; + }); + } + + /// Records the host's report on the encoder setup. + pub fn set_report(&self, report: StreamReport) { + self.with(|s| { + s.report = Some(report); + s.report_seq += 1; + }); } /// Records the decoder in use. @@ -128,6 +146,23 @@ impl Shared { Codec::Av1 => "av1", }); let color = &snap.stream_color; + let report = s.report.as_ref().map(|r| { + serde_json::json!({ + "wanted_kbps": r.wanted_kbps, + "user_bitrate": r.user_bitrate, + "ceiling_kbps": r.ceiling_kbps, + "attempts": r.attempts.iter().map(|a| serde_json::json!({ + "encoder": a.encoder, + "options": a.options, + "ok": a.ok, + "error": a.error, + })).collect::>(), + "rate_mode": r.rate.rate_mode().name(), + "quality": r.rate.quality, + "target_kbps": r.rate.target_kbps, + "max_kbps": r.rate.max_kbps, + }) + }); let v = serde_json::json!({ "phase": s.phase, "detail": s.detail, @@ -160,6 +195,8 @@ impl Shared { "input_note": snap.input_status.as_ref().map(|i| i.note.clone()), "client_flags": snap.client_options.map(|o| o.flags), "inputs_sent": snap.inputs_sent, + "report_seq": s.report_seq, + "report": report, }); v.to_string() } @@ -194,6 +231,61 @@ mod tests { assert_eq!(j["bit_depth"], 8); } + #[test] + fn json_has_the_stream_report_and_drops_it_with_the_stream() { + use ezd_proto::{EncoderAttempt, RateControl, RateMode}; + let sh = Shared::default(); + let j = |sh: &Shared| -> serde_json::Value { + serde_json::from_str(&sh.to_json(&Default::default())).unwrap() + }; + assert_eq!(j(&sh)["report_seq"], 0); + assert!(j(&sh)["report"].is_null()); + sh.set_report(StreamReport { + wanted_kbps: 45_949, + user_bitrate: false, + ceiling_kbps: 200_000, + attempts: vec![EncoderAttempt { + encoder: "h264_nvenc".into(), + options: "rc=vbr cq=19".into(), + ok: true, + error: String::new(), + }], + rate: RateControl { + mode: RateMode::Vbr.code(), + quality: 19, + target_kbps: 45_949, + max_kbps: 91_898, + }, + }); + let v = j(&sh); + assert_eq!(v["report_seq"], 1); + let r = &v["report"]; + assert_eq!(r["wanted_kbps"], 45_949); + assert_eq!(r["user_bitrate"], false); + assert_eq!(r["ceiling_kbps"], 200_000); + assert_eq!(r["attempts"][0]["encoder"], "h264_nvenc"); + assert_eq!(r["attempts"][0]["options"], "rc=vbr cq=19"); + assert_eq!(r["attempts"][0]["ok"], true); + assert_eq!(r["attempts"][0]["error"], ""); + assert_eq!(r["rate_mode"], "vbr"); + assert_eq!(r["quality"], 19); + assert_eq!( + (r["target_kbps"].as_u64(), r["max_kbps"].as_u64()), + (Some(45_949), Some(91_898)) + ); + sh.set_stream(StreamConfig { + width: 1, + height: 1, + refresh_mhz: 60_000, + codec: Codec::H264, + bitrate_kbps: 1, + encoder_name: "x".into(), + }); + let v = j(&sh); + assert!(v["report"].is_null()); + assert_eq!(v["report_seq"], 1); + } + #[test] fn json_reports_hdr10() { let sh = Shared::default(); diff --git a/crates/ezd-client-core/src/session.rs b/crates/ezd-client-core/src/session.rs index 8a29e9a..bf639c9 100644 --- a/crates/ezd-client-core/src/session.rs +++ b/crates/ezd-client-core/src/session.rs @@ -4,7 +4,7 @@ use crate::{ClientHandle, now_us}; use ezd_link::{Link, MuxError, MuxReceiver, MuxSender}; use ezd_proto::{ Bye, ClientCaps, ClientHdr, CursorShape, CursorState, FrameAck, Hello, Message, Ping, Pong, - Role, StreamColor, StreamConfig, VideoFrame, + Role, StreamColor, StreamConfig, StreamReport, VideoFrame, }; use std::time::Duration; use tokio::time::{MissedTickBehavior, interval, timeout}; @@ -47,6 +47,10 @@ pub trait ClientHandler: Send { /// `received_us` is its [`now_us`] at reassembly. fn on_video_frame(&mut self, frame: VideoFrame, received_us: u64) -> HandlerResult; + /// How the host negotiated and encodes the stream (protocol 0.7). Sent after the encoder + /// opens, before the first frame it produced, and again after every reopen. + fn on_stream_report(&mut self, _report: &StreamReport) {} + /// A new cursor shape (already cached in the handle). fn on_cursor_shape(&mut self, _shape: &CursorShape) {} @@ -413,6 +417,10 @@ fn on_message( handle.count_received(v.data.len()); flow(handler.on_video_frame(v, received_us)) } + Message::StreamReport(r) => { + handler.on_stream_report(&r); + Ok(None) + } Message::InputStatus(st) => { handle.store_input_status(st); Ok(None) diff --git a/crates/ezd-fake-client/src/lib.rs b/crates/ezd-fake-client/src/lib.rs index 89fd955..aed3bc9 100644 --- a/crates/ezd-fake-client/src/lib.rs +++ b/crates/ezd-fake-client/src/lib.rs @@ -20,7 +20,7 @@ use ezd_host_core::synthetic::read_frame_number; use ezd_link::{Link, MuxError}; use ezd_proto::{ ClientCaps, ClientOptions, Codec, CursorShape, CursorState, DecoderCaps, DisplayInfo, - InputStatus, StreamConfig, VideoFrame, + InputStatus, StreamConfig, StreamReport, VideoFrame, }; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -339,6 +339,10 @@ impl Handler { } impl ClientHandler for Handler { + fn on_stream_report(&mut self, report: &StreamReport) { + tracing::info!(?report, "host stream report"); + } + fn on_stream_config(&mut self, cfg: &StreamConfig) -> HandlerResult { self.report.stream = cfg.clone(); self.decoder = Some(Decoder::new(cfg.codec).map_err(boxed)?); diff --git a/crates/ezd-ffmpeg/src/encoder.rs b/crates/ezd-ffmpeg/src/encoder.rs index cc21e4c..86a42b4 100644 --- a/crates/ezd-ffmpeg/src/encoder.rs +++ b/crates/ezd-ffmpeg/src/encoder.rs @@ -200,8 +200,6 @@ impl Encoder { sw_fmt, }; let cfg = &enc.cfg; - let bitrate = cfg.bitrate_kbps as i64 * 1000; - let fps = cfg.refresh_mhz as f64 / 1000.0; // SAFETY: ctx is a freshly allocated, exclusively owned AVCodecContext; we only write plain fields. unsafe { let c = &mut *ctx; @@ -219,10 +217,10 @@ impl Encoder { Some(h) => h.kind.pix_fmt(), None => cfg.pix_fmt.av(), }; - c.bit_rate = bitrate; - c.rc_max_rate = bitrate; - // Two frames worth of VBV buffer: tight enough for low latency, loose enough for keyframes. - c.rc_buffer_size = ((bitrate as f64 * 2.0 / fps) as i32).max(1000); + let rate = crate::rate::rate_settings(cfg.bitrate_kbps, cfg.refresh_mhz); + c.bit_rate = rate.bit_rate; + c.rc_max_rate = rate.max_rate; + c.rc_buffer_size = rate.buffer_size; c.gop_size = i32::MAX; c.max_b_frames = 0; // Hardware encoders gain nothing from threads. libx264 needs them: one thread manages diff --git a/crates/ezd-ffmpeg/src/lib.rs b/crates/ezd-ffmpeg/src/lib.rs index 21d0b84..4e23719 100644 --- a/crates/ezd-ffmpeg/src/lib.rs +++ b/crates/ezd-ffmpeg/src/lib.rs @@ -23,6 +23,7 @@ mod error; mod hw; mod options; mod probe; +mod rate; mod source; #[cfg(target_os = "linux")] mod vulkan; @@ -37,6 +38,10 @@ pub use options::{option_sets, option_sets_for}; pub use probe::{ ProbeResult, encoder_available, probe_encoder, probe_encoder_with, software_encoders, }; +pub use rate::{ + NVENC_CQ, PEAK_FACTOR, RateSettings, VAAPI_QP, VBV_FRAMES, X264_CRF, describe_rate, + options_label, rate_settings, +}; pub use source::SwVideoSource; #[cfg(target_os = "linux")] pub use vulkan::vulkan_queue_families; diff --git a/crates/ezd-ffmpeg/src/options.rs b/crates/ezd-ffmpeg/src/options.rs index 7633d1e..f814a8c 100644 --- a/crates/ezd-ffmpeg/src/options.rs +++ b/crates/ezd-ffmpeg/src/options.rs @@ -28,6 +28,7 @@ fn base_option_sets(name: &str) -> Vec> { "libx264" => vec![vec![ s("preset", "ultrafast"), s("tune", "zerolatency"), + s("crf", &crate::rate::X264_CRF.to_string()), s("forced-idr", "1"), // Keep in-band SPS/PPS on keyframes in addition to the global-header extradata. s( @@ -42,11 +43,13 @@ fn base_option_sets(name: &str) -> Vec> { s("zerolatency", "1"), s("delay", "0"), s("bf", "0"), - s("rc", "cbr"), + // Constant quality inside the VBV: a sharp first IDR and few bits on still content. + s("rc", "vbr"), + s("cq", &crate::rate::NVENC_CQ.to_string()), s("forced-idr", "1"), ]], // Level 5.2: FFmpeg picks level 6.x above 5.2's macroblock rate (2960x1848 at 120 Hz), - // and Intel's iHD driver then fails every CBR frame ("internal encoding error") on + // and Intel's iHD driver then fails every rate-controlled frame ("internal encoding error") on // Gen 9.5. 5.2 still covers the frame size and buffering, and the tablet's decoder // accepts it. // @@ -57,31 +60,31 @@ fn base_option_sets(name: &str) -> Vec> { // frame from queueing up behind the link. "h264_vaapi" => vec![ vec![ - s("rc_mode", "CBR"), + s("rc_mode", "VBR"), s("low_power", "1"), s("async_depth", "1"), s("level", "5.2"), ], vec![ s("rc_mode", "CQP"), - s("qp", "24"), + s("qp", &crate::rate::VAAPI_QP.to_string()), s("low_power", "1"), s("async_depth", "1"), s("level", "5.2"), ], vec![ - s("rc_mode", "CBR"), + s("rc_mode", "VBR"), s("async_depth", "1"), s("level", "5.2"), ], ], "hevc_vaapi" | "av1_vaapi" => vec![ vec![ - s("rc_mode", "CBR"), + s("rc_mode", "VBR"), s("low_power", "1"), s("async_depth", "1"), ], - vec![s("rc_mode", "CBR"), s("async_depth", "1")], + vec![s("rc_mode", "VBR"), s("async_depth", "1")], ], "h264_qsv" | "hevc_qsv" | "av1_qsv" => vec![ vec![ @@ -101,12 +104,16 @@ fn base_option_sets(name: &str) -> Vec> { let sets: [&[(&'static str, &str)]; 6] = [ &[ ("usage", "ultralowlatency"), - ("rc", "cbr"), + ("rc", "vbr_peak"), + ("quality", "speed"), + ], + &[ + ("usage", "lowlatency"), + ("rc", "vbr_peak"), ("quality", "speed"), ], - &[("usage", "lowlatency"), ("rc", "cbr"), ("quality", "speed")], &[("usage", "lowlatency"), ("quality", "speed")], - &[("rc", "cbr"), ("quality", "speed")], + &[("rc", "vbr_peak"), ("quality", "speed")], &[("quality", "speed")], &[], ]; @@ -140,6 +147,24 @@ mod tests { let sets = option_sets(n); assert!(!sets.is_empty() && !sets[0].is_empty(), "{n}"); } + let has = |n: &str, kv: (&'static str, &str)| { + option_sets(n) + .iter() + .any(|set| set.contains(&(kv.0, kv.1.to_string()))) + }; + for n in ["h264_nvenc", "hevc_nvenc", "av1_nvenc"] { + assert!(has(n, ("rc", "vbr")) && has(n, ("cq", "19")), "{n}"); + assert!(!has(n, ("rc", "cbr")), "{n}"); + } + assert!(has("libx264", ("crf", "20"))); + for n in ["h264_vaapi", "hevc_vaapi", "av1_vaapi"] { + assert!( + has(n, ("rc_mode", "VBR")) && !has(n, ("rc_mode", "CBR")), + "{n}" + ); + } + assert!(has("h264_vaapi", ("qp", "20")) && !has("h264_vaapi", ("qp", "24"))); + assert!(has("h264_amf", ("rc", "vbr_peak")) && !has("h264_amf", ("rc", "cbr"))); assert_eq!(option_sets("unknown_enc"), vec![vec![]]); assert_eq!(option_sets("h264_vaapi").len(), 3); assert_eq!(option_sets("hevc_vaapi").len(), 2); diff --git a/crates/ezd-ffmpeg/src/rate.rs b/crates/ezd-ffmpeg/src/rate.rs new file mode 100644 index 0000000..a7f4d63 --- /dev/null +++ b/crates/ezd-ffmpeg/src/rate.rs @@ -0,0 +1,145 @@ +//! Rate-control numbers shared by every encoder context setup, and the report of what ran. + +use ezd_proto::{RateControl, RateMode}; + +/// The encoder may burst to this many times the negotiated bitrate. +pub const PEAK_FACTOR: u32 = 2; +/// VBV buffer size in frames at the peak rate. +pub const VBV_FRAMES: u32 = 4; +/// NVENC constant-quality target (`cq`). +pub const NVENC_CQ: u32 = 19; +/// libx264 constant rate factor. +pub const X264_CRF: u32 = 20; +/// Fixed quantiser of the VAAPI CQP fallback sets. +pub const VAAPI_QP: u32 = 20; + +/// Values for `bit_rate`, `rc_max_rate` and `rc_buffer_size` of an encoder context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateSettings { + /// Target bitrate in bit/s. + pub bit_rate: i64, + /// Peak bitrate in bit/s. + pub max_rate: i64, + /// VBV buffer size in bits. + pub buffer_size: i32, +} + +/// Context rate settings for a negotiated bitrate and refresh rate (millihertz). +pub fn rate_settings(kbps: u32, refresh_mhz: u32) -> RateSettings { + let bit_rate = kbps as i64 * 1000; + let max_rate = bit_rate * PEAK_FACTOR as i64; + let buffer = max_rate * VBV_FRAMES as i64 * 1000 / refresh_mhz.max(1) as i64; + RateSettings { + bit_rate, + max_rate, + buffer_size: buffer.clamp(1000, i32::MAX as i64) as i32, + } +} + +/// Rate-control description of the encoder `name` opened with `options`, for the stream report. +pub fn describe_rate(name: &str, options: &[(&str, String)], kbps: u32) -> RateControl { + let get = |key: &str| { + options + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v.as_str()) + }; + let num = |key: &str| get(key).and_then(|v| v.parse::().ok()).unwrap_or(0); + let (mode, quality) = if let Some(crf) = get("crf") { + (RateMode::Crf, crf.parse().unwrap_or(0)) + } else if let Some(rc) = get("rc") { + match rc { + "cbr" => (RateMode::Cbr, 0), + "vbr" | "vbr_peak" | "vbr_latency" => (RateMode::Vbr, num("cq")), + "cqp" => (RateMode::Cqp, num("qp")), + _ => (RateMode::Unknown, 0), + } + } else if let Some(rc) = get("rc_mode") { + match rc { + "CBR" => (RateMode::Cbr, 0), + "VBR" => (RateMode::Vbr, 0), + "CQP" => (RateMode::Cqp, num("qp")), + _ => (RateMode::Unknown, 0), + } + } else if name.ends_with("_qsv") { + // QSV runs VBR whenever the maximum rate is above the bitrate. + (RateMode::Vbr, 0) + } else { + (RateMode::Unknown, 0) + }; + RateControl { + mode: mode.code(), + quality, + target_kbps: kbps, + // A fixed quantiser ignores the rate limits. + max_kbps: if mode == RateMode::Cqp { + 0 + } else { + kbps.saturating_mul(PEAK_FACTOR) + }, + } +} + +/// Compact `k=v` label of the interesting options of a set, joined by spaces. +pub fn options_label(options: &[(&str, String)]) -> String { + const KEYS: [&str; 8] = [ + "rc", + "rc_mode", + "cq", + "qp", + "crf", + "low_power", + "profile", + "level", + ]; + options + .iter() + .filter(|(k, _)| KEYS.contains(k)) + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::option_sets; + + #[test] + fn settings_use_peak_and_four_frame_vbv() { + let r = rate_settings(45_949, 120_000); + assert_eq!(r.bit_rate, 45_949_000); + assert_eq!(r.max_rate, 91_898_000); + assert_eq!(r.buffer_size, (91_898_000i64 * 4 / 120) as i32); + assert!(rate_settings(1, 0).buffer_size >= 1000); + } + + #[test] + fn describes_the_set_that_opened() { + let d = |name: &str, i: usize| { + let sets = option_sets(name); + describe_rate(name, &sets[i], 1000) + }; + let nv = d("h264_nvenc", 0); + assert_eq!((nv.rate_mode(), nv.quality), (RateMode::Vbr, 19)); + assert_eq!((nv.target_kbps, nv.max_kbps), (1000, 2000)); + let x = d("libx264", 0); + assert_eq!((x.rate_mode(), x.quality), (RateMode::Crf, 20)); + let va = d("h264_vaapi", 0); + assert_eq!((va.rate_mode(), va.quality), (RateMode::Vbr, 0)); + let cqp = d("h264_vaapi", 1); + assert_eq!((cqp.rate_mode(), cqp.quality), (RateMode::Cqp, 20)); + assert_eq!(cqp.max_kbps, 0); + assert_eq!(d("h264_qsv", 0).rate_mode(), RateMode::Vbr); + assert_eq!(d("libopenh264", 0).rate_mode(), RateMode::Unknown); + } + + #[test] + fn label_keeps_only_interesting_keys() { + let sets = option_sets("h264_vaapi"); + assert_eq!( + options_label(&sets[1]), + "rc_mode=CQP qp=20 low_power=1 level=5.2" + ); + } +} diff --git a/crates/ezd-host-core/src/lib.rs b/crates/ezd-host-core/src/lib.rs index ed16486..cb6a60c 100644 --- a/crates/ezd-host-core/src/lib.rs +++ b/crates/ezd-host-core/src/lib.rs @@ -25,8 +25,8 @@ pub use session::{ run_host_session, run_host_session_with, }; pub use source::{ - CursorSink, DisplayMode, EncodedFrame, OutputInfo, SourceError, SourceHdr, VideoSource, - VirtualDisplay, + CursorSink, DisplayMode, EncodedFrame, EncoderSetup, OutputInfo, SourceError, SourceHdr, + VideoSource, VirtualDisplay, }; pub use synthetic::{PixelFormat, SyntheticFrames, read_frame_number}; pub use time::now_us; diff --git a/crates/ezd-host-core/src/session.rs b/crates/ezd-host-core/src/session.rs index c5eafc1..cce08e9 100644 --- a/crates/ezd-host-core/src/session.rs +++ b/crates/ezd-host-core/src/session.rs @@ -27,7 +27,7 @@ use ezd_link::{Link, MuxClosed, MuxError, MuxOptions, MuxReceiver, MuxSender, Vi use ezd_proto::{ Bye, ClientCaps, ClientHdr, ClientOptions, ClientStats, ClockSync, Codec, DEFAULT_CHUNK_LEN, DynamicRange, Hello, HostCaps, InputStatus, Message, Ping, Pong, Prefs, ProtoError, Reject, - Role, StreamColor, StreamConfig, VideoFrame, negotiate_hdr, + Role, StreamColor, StreamConfig, StreamReport, VideoFrame, negotiate_hdr, }; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -357,6 +357,13 @@ pub async fn run_host_session_with( { let (shared, tx, cfg) = (shared.clone(), tx.clone(), cfg.clone()); + let report_base = client_hello.supports_report().then(|| ReportBase { + wanted_kbps: prefs.bitrate_kbps.unwrap_or_else(|| { + ezd_proto::auto_bitrate_kbps(cfg.width, cfg.height, cfg.refresh_mhz, cfg.codec) + }), + user_bitrate: prefs.bitrate_kbps.is_some(), + ceiling_kbps: ezd_proto::bitrate_ceiling_kbps(&prefs), + }); let input_for_video = input_tx.clone(); let handle = tokio::runtime::Handle::current(); let poll = opts.poll_interval; @@ -371,7 +378,7 @@ pub async fn run_host_session_with( if let Some(t) = &input_for_video { let _ = t.send(InputCmd::Output(source.output())); } - video_loop(source.as_mut(), &tx, &shared, &handle, poll) + video_loop(source.as_mut(), &tx, &shared, &handle, poll, report_base) } Err(e) => Err(e), }; @@ -802,12 +809,20 @@ fn reject(tx: &MuxSender, reason: String) { let _ = tx.send_control(Message::Reject(Reject { reason })); } +/// Negotiation side of the `StreamReport`; the encoder side comes from the source. +struct ReportBase { + wanted_kbps: u32, + user_bitrate: bool, + ceiling_kbps: u32, +} + fn video_loop( source: &mut dyn VideoSource, tx: &MuxSender, sh: &VideoShared, handle: &tokio::runtime::Handle, poll: Duration, + report_base: Option, ) -> Result<(), SourceError> { let mut last_config: Option> = None; let mut next_id = 0u64; @@ -844,6 +859,30 @@ fn video_loop( let Some(f) = source.next_frame(poll)? else { continue; }; + // The encoder opened (again) to produce this frame: tell the client how, before it. + if let Some(setup) = source.take_encoder_setup() { + let report = StreamReport { + wanted_kbps: report_base.as_ref().map_or(0, |b| b.wanted_kbps), + user_bitrate: report_base.as_ref().is_some_and(|b| b.user_bitrate), + ceiling_kbps: report_base.as_ref().map_or(0, |b| b.ceiling_kbps), + attempts: setup.attempts, + rate: setup.rate, + }; + tracing::info!( + wanted_kbps = report.wanted_kbps, + user_bitrate = report.user_bitrate, + ceiling_kbps = report.ceiling_kbps, + rate_mode = report.rate.rate_mode().name(), + quality = report.rate.quality, + target_kbps = report.rate.target_kbps, + max_kbps = report.rate.max_kbps, + attempts = ?report.attempts, + "encoder setup" + ); + if report_base.is_some() && tx.send_control(Message::StreamReport(report)).is_err() { + return Ok(()); + } + } let id = f.frame_id.unwrap_or(next_id); next_id = id + 1; if let Some(cfg) = f.codec_config @@ -1203,6 +1242,123 @@ mod tests { (seen, told) } + /// A source that offers an encoder setup once, after its first frame. + struct ReportingSource { + inner: ScriptedSource, + pending: bool, + offered: bool, + } + + impl VideoSource for ReportingSource { + fn encoders(&self) -> Vec { + self.inner.encoders() + } + fn start(&mut self, cfg: &StreamConfig, cursor: CursorSink) -> Result<(), SourceError> { + self.inner.start(cfg, cursor) + } + fn next_frame(&mut self, t: Duration) -> Result, SourceError> { + let f = self.inner.next_frame(t); + self.pending = !std::mem::replace(&mut self.offered, true); + f + } + fn take_encoder_setup(&mut self) -> Option { + if !std::mem::take(&mut self.pending) { + return None; + } + Some(crate::source::EncoderSetup { + attempts: vec![ezd_proto::EncoderAttempt { + encoder: "enc".into(), + options: "rc=vbr".into(), + ok: true, + error: String::new(), + }], + rate: ezd_proto::RateControl { + mode: ezd_proto::RateMode::Vbr.code(), + quality: 19, + target_kbps: 1000, + max_kbps: 2000, + }, + }) + } + fn request_keyframe(&mut self) { + self.inner.request_keyframe() + } + fn stop(&mut self) { + self.inner.stop() + } + } + + /// Messages a client of `minor` receives up to its first few video frames. + async fn messages_until_video(minor: u16) -> Vec { + let (host_link, client_link) = Link::duplex_pair(1 << 20); + let src = ReportingSource { + inner: ScriptedSource::new(Duration::from_millis(5), 3000), + pending: false, + offered: false, + }; + let host = tokio::spawn(run_host_session_with( + host_link, + Box::new(src), + Prefs::default(), + fast_opts(), + )); + let (tx, mut rx, _t) = client_link.into_mux(DEFAULT_CHUNK_LEN); + let mut hello = Hello::new(Role::Client, "t", "client"); + hello.proto_minor = minor; + tx.send_control(Message::Hello(hello)).unwrap(); + tx.send_control(Message::ClientCaps(caps())).unwrap(); + tx.send_control(Message::ClientHdr(ClientHdr::none())) + .unwrap(); + loop { + if matches!(recv(&mut rx).await, Message::StreamConfig(_)) { + break; + } + } + tx.send_control(Message::ConfigAck).unwrap(); + let mut seen = Vec::new(); + let mut videos = 0; + while videos < 5 { + let m = recv(&mut rx).await; + videos += matches!(m, Message::VideoFrame(_)) as usize; + seen.push(m); + } + tx.send_control(Message::Bye(Bye { reason: "t".into() })) + .unwrap(); + let _ = timeout(Duration::from_secs(10), host).await; + seen + } + + #[tokio::test] + async fn report_precedes_the_first_frame_for_a_new_client() { + let seen = messages_until_video(ezd_proto::MINOR_REPORT).await; + let report = seen + .iter() + .position(|m| matches!(m, Message::StreamReport(_))) + .expect("no StreamReport"); + let first_video = seen + .iter() + .position(|m| matches!(m, Message::VideoFrame(_))) + .unwrap(); + assert!(report < first_video, "{seen:?}"); + let Message::StreamReport(r) = &seen[report] else { + unreachable!() + }; + assert!(!r.user_bitrate && r.wanted_kbps > 0 && r.ceiling_kbps >= r.wanted_kbps); + assert_eq!(r.attempts.len(), 1); + assert_eq!(r.rate.quality, 19); + let count = seen + .iter() + .filter(|m| matches!(m, Message::StreamReport(_))) + .count(); + assert_eq!(count, 1, "one setup, one report"); + } + + #[tokio::test] + async fn old_client_gets_no_report() { + let seen = messages_until_video(ezd_proto::MINOR_REPORT - 1).await; + assert!(!seen.iter().any(|m| matches!(m, Message::StreamReport(_)))); + } + #[tokio::test] async fn hdr10_is_negotiated_with_a_capable_client() { let hdr = ClientHdr::new(true, 1000, 600, 50, &[Codec::Hevc]); diff --git a/crates/ezd-host-core/src/source.rs b/crates/ezd-host-core/src/source.rs index d3ef480..78a4f9b 100644 --- a/crates/ezd-host-core/src/source.rs +++ b/crates/ezd-host-core/src/source.rs @@ -2,8 +2,8 @@ use ezd_link::{MuxClosed, MuxSender}; use ezd_proto::{ - Codec, CursorShape, CursorState, DynamicRange, EncoderCaps, Mastering, StreamColor, - StreamConfig, + Codec, CursorShape, CursorState, DynamicRange, EncoderAttempt, EncoderCaps, Mastering, + RateControl, StreamColor, StreamConfig, }; use std::collections::HashSet; use std::sync::{Arc, Mutex}; @@ -138,6 +138,15 @@ impl SourceHdr { } } +/// What a source's encoder tried and what it runs with, for the `StreamReport` sent to the client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncoderSetup { + /// Encoders and option sets tried, in order. + pub attempts: Vec, + /// Rate control of the encoder that opened. + pub rate: RateControl, +} + /// A capture + encode pipeline for one virtual display. /// /// Implemented per platform (and by `ezd_ffmpeg::SwVideoSource` for tests). @@ -191,6 +200,13 @@ pub trait VideoSource: Send { None } + /// The setup of the encoder that opened since the last call, `None` when nothing changed + /// (the default for sources that do not report). The session polls it after every frame + /// from `next_frame` and sends it to the client before that frame. + fn take_encoder_setup(&mut self) -> Option { + None + } + /// Makes the next frame returned by `next_frame` a keyframe. fn request_keyframe(&mut self); diff --git a/crates/ezd-host-linux/src/encode.rs b/crates/ezd-host-linux/src/encode.rs index 24c55a5..4866002 100644 --- a/crates/ezd-host-linux/src/encode.rs +++ b/crates/ezd-host-linux/src/encode.rs @@ -10,7 +10,8 @@ use ezd_ffmpeg::ColorInfo; use ezd_ffmpeg::HwDevice; use ezd_ffmpeg::HwKind; use ezd_ffmpeg::sys as ff; -use ezd_host_core::now_us; +use ezd_host_core::{EncoderSetup, now_us}; +use ezd_proto::EncoderAttempt; use std::ffi::{CStr, CString, c_void}; use std::os::fd::IntoRawFd; use std::ptr; @@ -345,6 +346,16 @@ struct Enc { ctx: *mut ff::AVCodecContext, pkt: *mut ff::AVPacket, extradata: Vec, + /// What `open` tried and what it runs with; taken by the `Encoder` once. + setup: Option, +} + +/// Longest error text kept in an [`EncoderAttempt`]. +const ATTEMPT_ERROR_MAX: usize = 120; + +/// `text` cut to what fits an [`EncoderAttempt`] error. +pub(crate) fn short_error(text: &str) -> String { + text.chars().take(ATTEMPT_ERROR_MAX).collect() } // SAFETY: exclusively owned FFmpeg objects, moved between threads only as a whole. @@ -421,11 +432,28 @@ impl Enc { return Err(format!("encoder {} not available in this FFmpeg", p.name)); } let mut last = String::from("no option set"); + let mut attempts = Vec::new(); for set in ezd_ffmpeg::option_sets_for(p.name, p.ten_bit) { + let mut attempt = EncoderAttempt { + encoder: p.name.to_string(), + options: ezd_ffmpeg::options_label(&set), + ok: true, + error: String::new(), + }; match Self::try_open(codec, p, &set) { - Ok(e) => return Ok(e), + Ok(mut e) => { + attempts.push(attempt); + e.setup = Some(EncoderSetup { + attempts, + rate: ezd_ffmpeg::describe_rate(p.name, &set, p.kbps), + }); + return Ok(e); + } Err(e) => { tracing::debug!("opening {} with {set:?} failed: {e}", p.name); + attempt.ok = false; + attempt.error = short_error(&e); + attempts.push(attempt); last = e; } } @@ -449,9 +477,8 @@ impl Enc { ctx, pkt: ptr::null_mut(), extradata: Vec::new(), + setup: None, }; - let bitrate = p.kbps as i64 * 1000; - let fps = p.fps_mhz as f64 / 1000.0; let c = &mut *ctx; c.width = p.width as i32; c.height = p.height as i32; @@ -464,9 +491,10 @@ impl Enc { den: 1000, }; c.pix_fmt = p.pix_fmt; - c.bit_rate = bitrate; - c.rc_max_rate = bitrate; - c.rc_buffer_size = ((bitrate as f64 * 2.0 / fps) as i32).max(1000); + let rate = ezd_ffmpeg::rate_settings(p.kbps, p.fps_mhz); + c.bit_rate = rate.bit_rate; + c.rc_max_rate = rate.max_rate; + c.rc_buffer_size = rate.buffer_size; c.gop_size = i32::MAX; c.max_b_frames = 0; c.thread_count = 1; @@ -1180,6 +1208,8 @@ pub struct Encoder { fps_mhz: u32, kbps: u32, active: Option, + /// Setup of the encoder opened last, until the source takes it. + setup: Option, } impl Encoder { @@ -1191,9 +1221,15 @@ impl Encoder { fps_mhz, kbps, active: None, + setup: None, } } + /// The setup of the encoder opened since the last call (every open makes a new one). + pub fn take_setup(&mut self) -> Option { + self.setup.take() + } + /// Sets the colour signalling of the stream (default BT.709 limited range). pub fn with_tags(mut self, tags: StreamTags) -> Encoder { self.tags = tags; @@ -1211,7 +1247,7 @@ impl Encoder { self.active = None; // SAFETY: valid frame; the frames context outlives the call. let hw = unsafe { (*p.frame.0).hw_frames_ctx }; - let enc = Enc::open(&EncParams { + let mut enc = Enc::open(&EncParams { name: self.name, width: p.width, height: p.height, @@ -1222,6 +1258,7 @@ impl Encoder { color: self.tags.color, ten_bit: ten_bit_encoder(self.name), })?; + self.setup = enc.setup.take(); self.active = Some(Active { generation: p.generation, enc, @@ -1308,6 +1345,11 @@ impl Pipeline { } } + /// The setup of the encoder opened since the last call. + pub fn take_setup(&mut self) -> Option { + self.enc.take_setup() + } + /// Sets the colour signalling of the stream (default BT.709 limited range). pub fn with_tags(self, tags: StreamTags) -> Pipeline { Pipeline { @@ -2537,10 +2579,25 @@ mod tests { assert!(p.release.is_some()); let out = std::thread::spawn(move || { let mut enc = Encoder::new("h264_nvenc", 60_000, 8_000); - enc.encode(p, false).expect("encode").expect("packet") + let out = enc.encode(p, false).expect("encode").expect("packet"); + (out, enc.take_setup(), enc.take_setup()) }) .join() .unwrap(); + let (out, setup, again) = out; + // The open made one setup: constant quality inside the peak, taken once. + let setup = setup.expect("setup after the open"); + assert!(again.is_none()); + assert_eq!(setup.attempts.len(), 1); + assert!(setup.attempts[0].ok && setup.attempts[0].options.contains("rc=vbr cq=19")); + assert_eq!( + (setup.rate.mode, setup.rate.quality), + (ezd_proto::RateMode::Vbr.code(), 19) + ); + assert_eq!( + (setup.rate.target_kbps, setup.rate.max_kbps), + (8_000, 16_000) + ); assert!(out.keyframe && out.codec_config.is_some()); check_colors(&decode_first(&out)); } diff --git a/crates/ezd-host-linux/src/source.rs b/crates/ezd-host-linux/src/source.rs index 66f74a5..7fe54e8 100644 --- a/crates/ezd-host-linux/src/source.rs +++ b/crates/ezd-host-linux/src/source.rs @@ -5,7 +5,7 @@ use crate::adapter::{self, RenderNode}; use crate::ahead::{ConvertAhead, Item, Start}; use crate::encode::{ Converter, Encoder, Kind, Pipeline, Plan, StreamTags, plan_encoders, plan_hdr_encoders, - probe_plan, + probe_plan, short_error, }; use crate::frame::{Frame, NegotiatedFormat}; use crate::portal::SourceKind; @@ -14,9 +14,10 @@ use crate::pw::{Frames, PwCapture, PwParams, default_intel_modifiers, default_nv use crate::wl_capture::WlCapture; use crate::x11_capture::X11Capture; use ezd_host_core::{ - CursorSink, EncodedFrame, OutputInfo, PlatformOptions, SourceError, SourceHdr, VideoSource, + CursorSink, EncodedFrame, EncoderSetup, OutputInfo, PlatformOptions, SourceError, SourceHdr, + VideoSource, }; -use ezd_proto::{Codec, DynamicRange, EncoderCaps, StreamColor, StreamConfig}; +use ezd_proto::{Codec, DynamicRange, EncoderAttempt, EncoderCaps, StreamColor, StreamConfig}; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -90,6 +91,8 @@ pub struct LinuxSource { force_key: bool, /// The output the stream shows (virtual output or the captured monitor), after `start`. output: Option, + /// Encoders the probes found unusable, reported with the encoder setup. + probe_failures: Vec, } /// Converts and encodes capture frames. @@ -202,14 +205,29 @@ impl LinuxSource { tracing::warn!("no DRM render node found; software encoding only"); } let node = node.map(|s| s.node); - let plans = plan_encoders(node.as_ref(), &|p| plan_works(p).is_ok()); + // Encoders that were probed unusable, in plan order, for the stream report. + let probe_failures = std::cell::RefCell::new(Vec::new()); + let works = |p: &Plan| match plan_works(p) { + Ok(()) => true, + Err(e) => { + probe_failures.borrow_mut().push(EncoderAttempt { + encoder: p.name.to_string(), + options: "probe".into(), + ok: false, + error: short_error(&e), + }); + false + } + }; + let plans = plan_encoders(node.as_ref(), &works); let hdr_source = observed_hdr(); // Probing opens a Main10 encoder: only worth it when the compositor offers HDR10. let hdr_plans = if hdr_source.is_some() { - plan_hdr_encoders(node.as_ref(), &|p| plan_works(p).is_ok()) + plan_hdr_encoders(node.as_ref(), &works) } else { Vec::new() }; + let probe_failures = probe_failures.into_inner(); LinuxSource { provider, kind: if opts.output_name.is_some() { @@ -227,6 +245,7 @@ impl LinuxSource { running: None, force_key: false, output: None, + probe_failures, } } @@ -486,6 +505,19 @@ impl VideoSource for LinuxSource { self.output.clone() } + fn take_encoder_setup(&mut self) -> Option { + let r = self.running.as_mut()?; + let mut setup = match &mut r.input { + Input::Serial(pipeline) => pipeline.take_setup(), + Input::Ahead { enc, .. } => enc.take_setup(), + }?; + // Probe failures happened before any open: they come first. + let mut attempts = self.probe_failures.clone(); + attempts.append(&mut setup.attempts); + setup.attempts = attempts; + Some(setup) + } + fn request_keyframe(&mut self) { self.force_key = true; } diff --git a/crates/ezd-host-windows/src/win/ffenc.rs b/crates/ezd-host-windows/src/win/ffenc.rs index d7ec178..bebb9b5 100644 --- a/crates/ezd-host-windows/src/win/ffenc.rs +++ b/crates/ezd-host-windows/src/win/ffenc.rs @@ -388,8 +388,6 @@ impl ZeroCopyEncoder { return Err("avcodec_alloc_context3 failed".into()); } self.ctx = ctx; - let bitrate = bitrate_kbps as i64 * 1000; - let fps = refresh_mhz as f64 / 1000.0; let c = &mut *ctx; c.width = width as i32; c.height = height as i32; @@ -402,9 +400,10 @@ impl ZeroCopyEncoder { den: 1000, }; c.pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_D3D11; - c.bit_rate = bitrate; - c.rc_max_rate = bitrate; - c.rc_buffer_size = ((bitrate as f64 * 2.0 / fps) as i32).max(1000); + let rate = ezd_ffmpeg::rate_settings(bitrate_kbps, refresh_mhz); + c.bit_rate = rate.bit_rate; + c.rc_max_rate = rate.max_rate; + c.rc_buffer_size = rate.buffer_size; c.gop_size = i32::MAX; c.max_b_frames = 0; c.thread_count = 1; diff --git a/crates/ezd-proto/src/lib.rs b/crates/ezd-proto/src/lib.rs index c35c340..1140be7 100644 --- a/crates/ezd-proto/src/lib.rs +++ b/crates/ezd-proto/src/lib.rs @@ -26,19 +26,22 @@ pub use frame::{ }; pub use msg::*; pub use negotiate::{ - DynamicRange, HdrMode, LinkKind, Prefs, SdrReason, negotiate, negotiate_hdr, pick_dynamic_range, + DynamicRange, HdrMode, LinkKind, Prefs, SdrReason, auto_bitrate_kbps, bitrate_ceiling_kbps, + negotiate, negotiate_hdr, pick_dynamic_range, }; /// Wire protocol major version. A mismatch is fatal at handshake. pub const PROTOCOL_MAJOR: u16 = 0; /// Wire protocol minor version. Newer minors may add new `(channel, kind)` pairs. -pub const PROTOCOL_MINOR: u16 = 6; +pub const PROTOCOL_MINOR: u16 = 7; /// First protocol minor with the input messages, `InputStatus` and `ClientOptions`. pub const MINOR_INPUT: u16 = 3; /// First protocol minor with `ClientHdr` and `StreamColor` (HDR10 streaming). pub const MINOR_HDR: u16 = 5; /// First protocol minor with `ClientKeyboard` (hardware keyboard attached to the client). pub const MINOR_KEYBOARD: u16 = 6; +/// First protocol minor with `StreamReport` (how the host negotiated and encodes the stream). +pub const MINOR_REPORT: u16 = 7; /// Default TCP port used for the host listener and `adb reverse`. pub const DEFAULT_PORT: u16 = 27183; diff --git a/crates/ezd-proto/src/msg.rs b/crates/ezd-proto/src/msg.rs index 0edb8bd..e2a4e2a 100644 --- a/crates/ezd-proto/src/msg.rs +++ b/crates/ezd-proto/src/msg.rs @@ -96,6 +96,12 @@ impl Hello { self.proto_minor >= crate::MINOR_KEYBOARD } + /// True if the peer speaks at least protocol minor [`MINOR_REPORT`](crate::MINOR_REPORT) + /// (`StreamReport`). + pub fn supports_report(&self) -> bool { + self.proto_minor >= crate::MINOR_REPORT + } + /// Fails with [`ProtoError::VersionMismatch`] if the peer's major differs from ours. pub fn check_compatible(&self) -> Result<()> { if self.proto_major != crate::PROTOCOL_MAJOR { @@ -551,6 +557,108 @@ pub const H273_TRANSFER_PQ: u8 = 16; /// H.273 matrix coefficients code of BT.2020 non-constant luminance. pub const H273_MATRIX_BT2020_NCL: u8 = 9; +/// How the encoder rate control is set up (see [`RateControl::mode`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RateMode { + /// Not known (an encoder default, or a code from a newer host). + Unknown, + /// Constant bitrate. + Cbr, + /// Variable bitrate, optionally with a constant-quality target. + Vbr, + /// Constant quantiser. + Cqp, + /// Constant rate factor (x264). + Crf, +} + +impl RateMode { + /// Wire code of this mode. + pub fn code(self) -> u8 { + match self { + RateMode::Unknown => 0, + RateMode::Cbr => 1, + RateMode::Vbr => 2, + RateMode::Cqp => 3, + RateMode::Crf => 4, + } + } + + /// Mode for a wire code; unknown codes map to [`RateMode::Unknown`]. + pub fn from_code(code: u8) -> RateMode { + match code { + 1 => RateMode::Cbr, + 2 => RateMode::Vbr, + 3 => RateMode::Cqp, + 4 => RateMode::Crf, + _ => RateMode::Unknown, + } + } + + /// Short lowercase name (`"cbr"`, `"vbr"`, `"cqp"`, `"crf"`, `"unknown"`). + pub fn name(self) -> &'static str { + match self { + RateMode::Unknown => "unknown", + RateMode::Cbr => "cbr", + RateMode::Vbr => "vbr", + RateMode::Cqp => "cqp", + RateMode::Crf => "crf", + } + } +} + +/// Rate control of the running encoder, part of [`StreamReport`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RateControl { + /// [`RateMode`] code. + pub mode: u8, + /// Quality value of the mode (cq, qp or crf); 0 when the mode has none. + pub quality: u16, + /// Target bitrate in kbit/s. + pub target_kbps: u32, + /// Peak bitrate in kbit/s the encoder may burst to. + pub max_kbps: u32, +} + +impl RateControl { + /// The mode as an enum. + pub fn rate_mode(&self) -> RateMode { + RateMode::from_code(self.mode) + } +} + +/// One encoder and option set the host tried, part of [`StreamReport`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EncoderAttempt { + /// FFmpeg encoder name. + pub encoder: String, + /// Compact label of the interesting options (`k=v` joined by spaces), or `probe`. + pub options: String, + /// True when this attempt opened (or, for `probe`, was usable). + pub ok: bool, + /// Short error text when `ok` is false (empty otherwise). + pub error: String, +} + +/// How the host negotiated and encodes the stream (host to client, protocol 0.7). +/// +/// Sent after the encoder opens, before the first frame it produced, and again after every reopen, +/// when the client `Hello` has minor >= [`MINOR_REPORT`](crate::MINOR_REPORT). Informational only. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StreamReport { + /// Bitrate the negotiation wanted before the link ceiling (kbit/s): derived from size and + /// rate, or the user's fixed value. + pub wanted_kbps: u32, + /// True when the user fixed the bitrate instead of deriving it. + pub user_bitrate: bool, + /// Link ceiling applied (kbit/s). + pub ceiling_kbps: u32, + /// Encoders and option sets the host tried, in order. + pub attempts: Vec, + /// Rate control of the encoder that is running. + pub rate: RateControl, +} + /// Whether a hardware keyboard is attached to the client (client to host, protocol 0.6). /// /// Sent when the state changes and right after the handshake, when the host `Hello` has minor >= @@ -698,7 +806,7 @@ impl Default for StreamColor { } /// Any message of protocol 0.x (0.2 added `FrameAck`, 0.3 the input messages, -/// `InputStatus` and `ClientOptions`, 0.5 `ClientHdr` and `StreamColor`, 0.6 `ClientKeyboard`). +/// `InputStatus` and `ClientOptions`, 0.5 `ClientHdr` and `StreamColor`, 0.6 `ClientKeyboard`, 0.7 `StreamReport`). #[derive(Debug, Clone, PartialEq)] #[allow(clippy::large_enum_variant)] pub enum Message { @@ -736,6 +844,8 @@ pub enum Message { StreamColor(StreamColor), /// Control/17 (0.6), client to host. ClientKeyboard(ClientKeyboard), + /// Control/18 (0.7), host to client. + StreamReport(StreamReport), /// Input/1 (0.3), client to host. Pen(PenSample), /// Input/2 (0.3), client to host. @@ -789,6 +899,7 @@ impl Message { Message::ClientHdr(_) => 15, Message::StreamColor(_) => 16, Message::ClientKeyboard(_) => 17, + Message::StreamReport(_) => 18, Message::Pen(_) => 1, Message::Touch(_) => 2, Message::PointerButton(_) => 3, @@ -825,6 +936,7 @@ pub fn encode_message(msg: &Message) -> Result<(Channel, u8, Vec)> { Message::ClientHdr(m) => enc(m)?, Message::StreamColor(m) => enc(m)?, Message::ClientKeyboard(m) => enc(m)?, + Message::StreamReport(m) => enc(m)?, Message::Pen(m) => enc(m)?, Message::Touch(m) => enc(m)?, Message::PointerButton(m) => enc(m)?, @@ -871,6 +983,7 @@ pub fn decode_message(channel: u8, kind: u8, payload: &[u8]) -> Result Message::ClientHdr(dec(c, k, payload)?), (Channel::Control, 16) => Message::StreamColor(dec(c, k, payload)?), (Channel::Control, 17) => Message::ClientKeyboard(dec(c, k, payload)?), + (Channel::Control, 18) => Message::StreamReport(dec(c, k, payload)?), (Channel::Input, 1) => Message::Pen(dec(c, k, payload)?), (Channel::Input, 2) => Message::Touch(dec(c, k, payload)?), (Channel::Input, 3) => Message::PointerButton(dec(c, k, payload)?), @@ -989,6 +1102,7 @@ mod tests { 400, )), Message::ClientKeyboard(ClientKeyboard { attached: true }), + Message::StreamReport(sample_report()), Message::Pen(PenSample { t_us: 1 << 40, x: 65535, @@ -1187,6 +1301,65 @@ mod tests { assert!(h.supports_hdr()); } + fn sample_report() -> StreamReport { + StreamReport { + wanted_kbps: 45_949, + user_bitrate: false, + ceiling_kbps: 200_000, + attempts: vec![ + EncoderAttempt { + encoder: "h264_vaapi".into(), + options: "rc_mode=VBR low_power=1".into(), + ok: false, + error: "Invalid argument".into(), + }, + EncoderAttempt { + encoder: "h264_vaapi".into(), + options: "rc_mode=CQP qp=20 low_power=1 level=5.2".into(), + ok: true, + error: String::new(), + }, + ], + rate: RateControl { + mode: RateMode::Cqp.code(), + quality: 20, + target_kbps: 45_949, + max_kbps: 91_898, + }, + } + } + + #[test] + fn report_support_by_minor() { + let mut h = Hello::new(Role::Client, "x", "y"); + assert!(h.supports_report()); + h.proto_minor = crate::MINOR_REPORT - 1; + assert!(!h.supports_report()); + assert!(h.supports_keyboard()); + } + + #[test] + fn stream_report_round_trips_and_tolerates_unknown_mode() { + let mut r = sample_report(); + r.rate.mode = 200; + let (ch, kind, p) = encode_message(&Message::StreamReport(r.clone())).unwrap(); + assert_eq!((ch, kind), (Channel::Control, 18)); + let Some(Message::StreamReport(back)) = decode_message(ch as u8, kind, &p).unwrap() else { + panic!("not a StreamReport"); + }; + assert_eq!(back, r); + assert_eq!(back.rate.rate_mode(), RateMode::Unknown); + for m in [ + RateMode::Unknown, + RateMode::Cbr, + RateMode::Vbr, + RateMode::Cqp, + RateMode::Crf, + ] { + assert_eq!(RateMode::from_code(m.code()), m); + } + } + #[test] fn stream_color_presets() { let sdr = StreamColor::sdr_bt709(); diff --git a/crates/ezd-proto/src/negotiate.rs b/crates/ezd-proto/src/negotiate.rs index d8bbbdb..0a66c8a 100644 --- a/crates/ezd-proto/src/negotiate.rs +++ b/crates/ezd-proto/src/negotiate.rs @@ -286,6 +286,20 @@ pub fn negotiate_hdr( Ok((negotiate(client, host, prefs)?, range)) } +/// Bitrate in kbit/s derived from the stream size, rate and codec (before the link ceiling). +pub fn auto_bitrate_kbps(width: u32, height: u32, refresh_mhz: u32, codec: Codec) -> u32 { + let fps = refresh_mhz as f64 / 1000.0; + (width as f64 * height as f64 * fps * bits_per_pixel(codec) / 1000.0).round() as u32 +} + +/// Bitrate ceiling in kbit/s that `prefs` impose on the negotiated bitrate. +pub fn bitrate_ceiling_kbps(prefs: &Prefs) -> u32 { + prefs + .max_bitrate_kbps + .unwrap_or_else(|| prefs.link.default_max_bitrate_kbps()) + .max(MIN_BITRATE_KBPS) +} + /// Computes the stream configuration for a client/host pair. /// /// * codec: `prefs.codec` if both sides support it, else H.264 (then HEVC, AV1); @@ -310,14 +324,10 @@ pub fn negotiate(client: &ClientCaps, host: &HostCaps, prefs: &Prefs) -> Result< prefs, ); let refresh_mhz = pick_refresh(client, dec, prefs); - let fps = refresh_mhz as f64 / 1000.0; - let ceiling = prefs - .max_bitrate_kbps - .unwrap_or_else(|| prefs.link.default_max_bitrate_kbps()) - .max(MIN_BITRATE_KBPS); - let wanted = prefs.bitrate_kbps.unwrap_or_else(|| { - (width as f64 * height as f64 * fps * bits_per_pixel(codec) / 1000.0).round() as u32 - }); + let ceiling = bitrate_ceiling_kbps(prefs); + let wanted = prefs + .bitrate_kbps + .unwrap_or_else(|| auto_bitrate_kbps(width, height, refresh_mhz, codec)); Ok(StreamConfig { width, height, diff --git a/docs/protocol.md b/docs/protocol.md index 0230d32..723207a 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,4 +1,4 @@ -# ezdisplay wire protocol (version 0.6) +# ezdisplay wire protocol (version 0.7) All integers are little endian. The protocol runs over any reliable ordered byte stream: TCP through `adb reverse` over USB, or a QUIC connection on Wi-Fi (see [Transports](#transports)). It is implemented in the `ezd-proto` @@ -57,6 +57,7 @@ flowchart LR | Control | 15 | `ClientHdr` | client to host | postcard (0.5) | | Control | 16 | `StreamColor` | host to client | postcard (0.5) | | Control | 17 | `ClientKeyboard` | client to host | postcard (0.6) | +| Control | 18 | `StreamReport` | host to client | postcard (0.7) | | Input | 1 | `Pen` (`PenSample`) | client to host | postcard (0.3) | | Input | 2 | `Touch` (`TouchFrame`) | client to host | postcard (0.3) | | Input | 3 | `PointerButton` | client to host | postcard (0.3) | @@ -112,9 +113,9 @@ per push) and yields complete messages. ### Versioning -`Hello` carries `proto_major` and `proto_minor` (currently 0 and 6; 0.2 added `FrameAck`, 0.3 +`Hello` carries `proto_major` and `proto_minor` (currently 0 and 7; 0.2 added `FrameAck`, 0.3 the input messages, `InputStatus` and `ClientOptions`, 0.4 the `Key` message, 0.5 `ClientHdr` and -`StreamColor` for HDR10, 0.6 `ClientKeyboard`; the Wi-Fi transport is below the framed protocol and +`StreamColor` for HDR10, 0.6 `ClientKeyboard`, 0.7 `StreamReport`; the Wi-Fi transport is below the framed protocol and needs no minor bump). A different major version is fatal: the receiver answers `Reject` and closes. Minor versions only add new `(channel, kind)` pairs, so peers with different minors interoperate. `Hello::supports_input()` is true @@ -126,6 +127,8 @@ host's `InputStatus.caps` has `KEYS`, and an older host would skip the unknown p `ClientHdr` only to such a host, and a host sends `StreamColor` only to such a client. `Hello::supports_keyboard()` is true when the peer's minor is at least `MINOR_KEYBOARD` = 6: a client sends `ClientKeyboard` only to such a host. +`Hello::supports_report()` is true when the peer's minor is at least `MINOR_REPORT` = 7: a host +sends `StreamReport` only to such a client. ## Transports @@ -297,6 +300,10 @@ sequenceDiagram H->>C: ClientOptions(flags) H->>C: InputStatus(caps, note), again whenever it changes end + opt client minor ≥ 7 + H->>C: StreamReport(negotiation, encoder attempts, rate control) + Note right of H: after the encoder opens, before its first frame,
and again after every reopen + end H->>C: VideoFrame(CODEC_CONFIG): parameter sets only H->>C: VideoFrame(KEYFRAME) loop every frame, one in flight (video credit) @@ -335,7 +342,10 @@ gives up (the host uses 10 s per step). * Refresh rate: the highest client rate not above `max_refresh_mhz` (default 120000) and the decoder `max_fps`. The encoder frame rate equals the refresh rate. * Bitrate: `width * height * fps * 0.07` bit/s for H.264 (`0.05` for HEVC and AV1), clamped to - the link ceiling (USB 80 Mbit/s, network 30 Mbit/s by default). + the link ceiling (USB 80 Mbit/s, network 30 Mbit/s by default). The bitrate is the encoder's + target, not a constant rate: the host encodes at constant quality (NVENC `cq` 19, x264 `crf` 20, + VAAPI VBR or fixed QP 20) with a peak of twice the bitrate and a VBV buffer of four frames at + that peak, so the first IDR is sharp and still content costs few bits. * Encoder: the first hardware encoder for the codec, otherwise the first software one. * Dynamic range (`pick_dynamic_range`, used through `negotiate_hdr`): HDR10 is streamed only when the source is HDR, `Prefs.hdr` is not `Off`, the client sent `ClientHdr` with @@ -478,6 +488,28 @@ white: [u16; 2], max_luminance: u32, min_luminance: u32 }` holds the red, green chromaticities and the white point in units of 0.00002, and the luminances in units of 0.0001 cd/m2. A client that receives no `StreamColor` assumes 8-bit limited-range BT.709, as before 0.5. +### Stream report (0.7) + +Host to client, on the Control channel, when the client minor is at least 7: +`StreamReport { wanted_kbps: u32, user_bitrate: bool, ceiling_kbps: u32, attempts: +Vec, rate: RateControl }`. It is informational: a client may show it and must not +depend on it. The host sends it after the encoder opens, before the first frame that encoder +produced, and again after every reopen (the encoder opens lazily on the first captured frame and +again when the capture format changes), so it can arrive after `StreamConfig` and the first +`CODEC_CONFIG` of a session never precedes it. + +* `wanted_kbps`: the bitrate the negotiation wanted before the link ceiling, derived from size, + rate and codec or the user's fixed value (`user_bitrate` = true); `ceiling_kbps` is the link + ceiling that clamped it into `StreamConfig.bitrate_kbps`. +* `EncoderAttempt { encoder: String, options: String, ok: bool, error: String }`: the encoders and + option sets the host tried, in order. `options` is a compact `k=v` label of the rate-control, + profile and level options, or `probe` for an encoder found unusable before the session. `error` + is a short text (at most 120 characters) when `ok` is false. +* `RateControl { mode: u8, quality: u16, target_kbps: u32, max_kbps: u32 }` of the encoder that + runs. `mode` is 0 unknown, 1 CBR, 2 VBR, 3 constant QP, 4 CRF (unknown codes are read as 0); + `quality` is the `cq`, `qp` or `crf` value (0 when the mode has none); `max_kbps` is the peak + rate, 0 for constant QP, which has none. + ## Cursor `CursorShape { shape_id, width, height, hot_x, hot_y, rgba }` is sent once per `shape_id` per From 7a5f5eb6a5e8cff3016af12eac84fa313542d9ca Mon Sep 17 00:00:00 2001 From: FacuM Date: Tue, 27 Oct 2026 01:13:15 -0300 Subject: [PATCH 2/5] Let the tablet choose the codec and a USB quality level Protocol 0.8 adds Control/22 ClientPrefs, sent by the client after ClientHdr: a preferred codec (auto, H.264, HEVC, AV1) and a quality level. Normal keeps quality-targeted VBR; High uses a constant QP of 10; Lossless uses HEVC lossless (4:2:0) and falls back to High when the encoder or codec cannot do it. Levels above Normal apply only over USB. StreamReport moves to Control/21 and reports the requested and applied quality and codec; 0.7 and Control/18-20 stay reserved for audio. The Linux host now offers 8-bit SDR HEVC next to H.264 (HEVC was HDR10-only), with the bit depth explicit on each encoder plan. ezd-host run gains --codec and --quality, which override the client. The Android settings gain a Video section with the codec and the USB quality, sent with the capabilities on every connection, and the OSD shows the requested and applied quality and a lossless headline. --- android/AGENTS.md | 3 +- .../com/librestatic/ezdisplay/Capabilities.kt | 25 ++ .../com/librestatic/ezdisplay/MainActivity.kt | 7 +- .../com/librestatic/ezdisplay/StreamOsd.kt | 41 ++- .../ezdisplay/settings/Sections.kt | 141 +++++++++++ .../ezdisplay/settings/SettingsScreen.kt | 16 +- .../ezdisplay/settings/SettingsStore.kt | 37 +++ .../app/src/main/res/values-de/strings.xml | 18 ++ .../app/src/main/res/values-es/strings.xml | 18 ++ .../app/src/main/res/values-fr/strings.xml | 18 ++ .../app/src/main/res/values-it/strings.xml | 18 ++ .../app/src/main/res/values-pt/strings.xml | 18 ++ android/app/src/main/res/values/strings.xml | 20 ++ .../librestatic/ezdisplay/StreamOsdTest.kt | 50 ++++ .../ezdisplay/settings/SettingsPhoneTest.kt | 10 + .../settings/SettingsScreenshotTest.kt | 10 + .../ezdisplay/settings/SettingsStoreTest.kt | 41 +++ .../ezdisplay/settings/SettingsWideTest.kt | 11 + crates/ezd-android/src/caps.rs | 65 ++++- crates/ezd-android/src/session.rs | 1 + crates/ezd-android/src/status.rs | 15 +- crates/ezd-client-core/src/session.rs | 12 +- .../i18n/de/ezd-fake-client.ftl | 2 + .../i18n/en/ezd-fake-client.ftl | 2 + .../i18n/es/ezd-fake-client.ftl | 2 + .../i18n/fr/ezd-fake-client.ftl | 2 + .../i18n/it/ezd-fake-client.ftl | 2 + .../i18n/pt/ezd-fake-client.ftl | 2 + crates/ezd-fake-client/src/lib.rs | 16 +- crates/ezd-fake-client/src/main.rs | 37 ++- crates/ezd-ffmpeg/src/encoder.rs | 6 +- crates/ezd-ffmpeg/src/lib.rs | 2 +- crates/ezd-ffmpeg/src/options.rs | 238 ++++++++++++++++-- crates/ezd-ffmpeg/src/rate.rs | 44 +++- crates/ezd-host-core/src/session.rs | 179 ++++++++++++- crates/ezd-host-core/src/source.rs | 6 +- crates/ezd-host-linux/src/ahead.rs | 1 + crates/ezd-host-linux/src/diag.rs | 5 +- crates/ezd-host-linux/src/encode.rs | 195 +++++++++++--- crates/ezd-host-linux/src/source.rs | 69 ++--- crates/ezd-host-service/src/config.rs | 64 ++++- crates/ezd-host-service/src/lib.rs | 5 +- crates/ezd-host-service/src/service.rs | 6 +- crates/ezd-host-windows/src/win/ffenc.rs | 5 +- crates/ezd-host/i18n/de/ezd-host.ftl | 2 + crates/ezd-host/i18n/en/ezd-host.ftl | 2 + crates/ezd-host/i18n/es/ezd-host.ftl | 2 + crates/ezd-host/i18n/fr/ezd-host.ftl | 2 + crates/ezd-host/i18n/it/ezd-host.ftl | 2 + crates/ezd-host/i18n/pt/ezd-host.ftl | 2 + crates/ezd-host/src/run.rs | 36 ++- crates/ezd-proto/src/lib.rs | 8 +- crates/ezd-proto/src/msg.rs | 170 ++++++++++++- crates/ezd-proto/src/negotiate.rs | 171 ++++++++++++- docs/protocol.md | 61 +++-- 55 files changed, 1786 insertions(+), 157 deletions(-) diff --git a/android/AGENTS.md b/android/AGENTS.md index ba22d4e..07a8916 100644 --- a/android/AGENTS.md +++ b/android/AGENTS.md @@ -16,7 +16,8 @@ Repository-wide rules (languages and localization, naming) are in the root `AGEN while a key editor is visible). There is no navigation rail. `BoxWithConstraints` is used instead of `WindowSizeClass` because the size classes put 841 and 1056 dp in the same 840 dp bucket. The Wide page measures its own width: >= 900 dp gives three profile columns and zoom beside touch, otherwise two columns and stacked. - At 1691x978 dp the Wide page must not scroll (covered by `SettingsWideTest`). + At 1691x978 dp the Wide page must not scroll (covered by `SettingsWideTest`); for that the Wide page shows Video + (codec and quality) as one row that opens a dialog, like the Connection hosts. - `Sections.kt`: shared components. Colours and type come from `MaterialTheme` only. ## Build and test diff --git a/android/app/src/main/kotlin/com/librestatic/ezdisplay/Capabilities.kt b/android/app/src/main/kotlin/com/librestatic/ezdisplay/Capabilities.kt index ab4ae25..af8d3e1 100644 --- a/android/app/src/main/kotlin/com/librestatic/ezdisplay/Capabilities.kt +++ b/android/app/src/main/kotlin/com/librestatic/ezdisplay/Capabilities.kt @@ -1,11 +1,15 @@ package com.librestatic.ezdisplay import android.app.Activity +import android.content.SharedPreferences import android.media.MediaCodecInfo import android.media.MediaCodecList import android.os.Build import android.view.Display import android.view.InputDevice +import com.librestatic.ezdisplay.settings.SettingsStore +import com.librestatic.ezdisplay.settings.StreamQualities +import com.librestatic.ezdisplay.settings.VideoCodecs import org.json.JSONArray import org.json.JSONObject import kotlin.math.max @@ -39,6 +43,26 @@ object Capabilities { return NativeModes(native.first, native.second, modes) } + /** True when the device lists a decoder for [codec] (`h264`, `hevc`, `av1`). */ + fun hasDecoder(codec: String): Boolean { + val mime = CODECS.firstOrNull { it.first == codec }?.second ?: return false + return MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.any { info -> + !info.isEncoder && info.supportedTypes.any { it.equals(mime, ignoreCase = true) } + } + } + + /** + * `stream_prefs` from the settings. HEVC without an HEVC decoder is sent as `auto`; unknown or missing values + * are `auto` / `normal`. + */ + fun streamPrefs(prefs: SharedPreferences, hevcAvailable: Boolean = hasDecoder("hevc")): JSONObject { + val codec = prefs.getString(SettingsStore.VIDEO_CODEC, null).takeIf { it in VideoCodecs } ?: "auto" + val quality = prefs.getString(SettingsStore.STREAM_QUALITY, null).takeIf { it in StreamQualities } ?: "normal" + return JSONObject() + .put("codec", if (codec == "hevc" && !hevcAvailable) "auto" else codec) + .put("quality", quality) + } + fun toJson(activity: Activity, chosen: Display.Mode?): String { val display = display(activity) val native = nativeModes(display) @@ -59,6 +83,7 @@ object Capabilities { .put("decoders", decoders(native.width, native.height)) .put("stylus", hasStylus()) .put("max_touch_points", 10) + .put("stream_prefs", streamPrefs(InputPrefs.prefs(activity))) return json.toString() } diff --git a/android/app/src/main/kotlin/com/librestatic/ezdisplay/MainActivity.kt b/android/app/src/main/kotlin/com/librestatic/ezdisplay/MainActivity.kt index 918da96..c0ed598 100644 --- a/android/app/src/main/kotlin/com/librestatic/ezdisplay/MainActivity.kt +++ b/android/app/src/main/kotlin/com/librestatic/ezdisplay/MainActivity.kt @@ -98,6 +98,7 @@ class MainActivity : Activity(), SurfaceHolder.Callback, Choreographer.FrameCall private val handler = Handler(Looper.getMainLooper()) private var chosenMode: android.view.Display.Mode? = null private var capsJson: String? = null + private val hevcAvailable by lazy { Capabilities.hasDecoder("hevc") } private var started = false // between onStart and onStop private var surfaceReady = false @@ -484,7 +485,11 @@ class MainActivity : Activity(), SurfaceHolder.Callback, Choreographer.FrameCall private fun connect() { if (!started || sessionActive) return if (!surfaceReady) return // surfaceCreated reschedules - val caps = capsJson ?: Capabilities.toJson(this, chosenMode).also { capsJson = it } + val base = capsJson ?: Capabilities.toJson(this, chosenMode).also { capsJson = it } + // Codec and quality may change in the settings while this activity stays alive. + val caps = JSONObject(base) + .put("stream_prefs", Capabilities.streamPrefs(InputPrefs.prefs(this), hevcAvailable)) + .toString() pushKeyboardState() val acc = accessory?.takeIf { usbManager.hasPermission(it) } val wifiChoice = if (tryWifiNext) wifiTarget() else null diff --git a/android/app/src/main/kotlin/com/librestatic/ezdisplay/StreamOsd.kt b/android/app/src/main/kotlin/com/librestatic/ezdisplay/StreamOsd.kt index d4e3b98..8a890b6 100644 --- a/android/app/src/main/kotlin/com/librestatic/ezdisplay/StreamOsd.kt +++ b/android/app/src/main/kotlin/com/librestatic/ezdisplay/StreamOsd.kt @@ -26,6 +26,10 @@ data class OsdReport( val quality: Int, val targetKbps: Int, val maxKbps: Int, + /** `normal` / `high` / `lossless`; `normal` and `auto` when an older host does not report them. */ + val wantedQuality: String = "normal", + val appliedQuality: String = "normal", + val wantedCodec: String = "auto", ) /** What the on-screen display needs from one status poll. */ @@ -64,6 +68,9 @@ object StreamOsd { quality = it.optInt("quality"), targetKbps = it.optInt("target_kbps"), maxKbps = it.optInt("max_kbps"), + wantedQuality = it.optString("wanted_quality", "normal"), + appliedQuality = it.optString("applied_quality", "normal"), + wantedCodec = it.optString("wanted_codec", "auto"), ) } return OsdInfo( @@ -94,6 +101,12 @@ object StreamOsd { return if (mbit < 10) String.format(locale(res), "%.1f", mbit) else String.format(locale(res), "%d", Math.round(mbit)) } + private fun qualityName(res: Resources, q: String): String = when (q) { + "high" -> res.getString(R.string.osd_quality_high) + "lossless" -> res.getString(R.string.osd_quality_lossless) + else -> res.getString(R.string.osd_quality_normal) + } + private fun hz(res: Resources, mhz: Int): String = if (mhz % 1000 == 0) (mhz / 1000).toString() else String.format(locale(res), "%.2f", mhz / 1000.0) @@ -117,6 +130,19 @@ object StreamOsd { } if (report.ceilingKbps > 0) parts += res.getString(R.string.osd_link_limit, mbit(res, report.ceilingKbps)) lines += parts.joinToString(" · ") + if (report.wantedQuality != "normal") { + lines += if (report.appliedQuality == report.wantedQuality) { + res.getString(R.string.osd_quality, qualityName(res, report.wantedQuality)) + } else { + res.getString( + R.string.osd_quality_applied, + qualityName(res, report.wantedQuality), qualityName(res, report.appliedQuality), + ) + } + } + if (report.wantedCodec != "auto" && !report.wantedCodec.equals(info.codec, ignoreCase = true)) { + lines += res.getString(R.string.osd_codec_wanted, codecName(report.wantedCodec)) + } val hidden = (report.attempts.size - MAX_ATTEMPTS).coerceAtLeast(0) if (hidden > 0) lines += res.getQuantityString(R.plurals.osd_more_attempts, hidden, hidden) for (a in report.attempts.drop(hidden)) { @@ -131,13 +157,18 @@ object StreamOsd { "vbr" -> res.getString(R.string.osd_rate_vbr, report.quality) "cqp" -> res.getString(R.string.osd_rate_cqp, report.quality) "crf" -> res.getString(R.string.osd_rate_crf, report.quality) + "lossless" -> res.getString(R.string.osd_rate_lossless) else -> null }?.let { headline += it } } - headline += if (report != null && report.maxKbps > info.bitrateKbps && info.bitrateKbps > 0) { - res.getString(R.string.osd_rate_peak, mbitNumber(res, info.bitrateKbps), mbitNumber(res, report.maxKbps)) - } else { - mbit(res, info.bitrateKbps) + // A fixed quantiser or lossless stream has no target rate: the negotiated one would mislead. + val fixedQuality = report?.rateMode == "cqp" || report?.rateMode == "lossless" + if (!fixedQuality) { + headline += if (report != null && report.maxKbps > info.bitrateKbps && info.bitrateKbps > 0) { + res.getString(R.string.osd_rate_peak, mbitNumber(res, info.bitrateKbps), mbitNumber(res, report.maxKbps)) + } else { + mbit(res, info.bitrateKbps) + } } return OsdContent(lines, headline.joinToString(" · ")) } @@ -149,7 +180,7 @@ class StreamOsdView(context: Context) : LinearLayout(context) { setTextColor(Color.WHITE) setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f) typeface = Typeface.MONOSPACE - maxLines = 8 + maxLines = 10 ellipsize = TextUtils.TruncateAt.END } private val headline = TextView(context).apply { diff --git a/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/Sections.kt b/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/Sections.kt index f07d42e..21ab638 100644 --- a/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/Sections.kt +++ b/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/Sections.kt @@ -17,10 +17,19 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.TextButton +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.annotation.StringRes +import androidx.compose.material3.ColorScheme import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -56,6 +65,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -428,6 +438,137 @@ private fun TriggerRow(t: Trigger, state: SettingsState, actions: SettingsAction } } +// ---------------------------------------------------------------- Video + +/** Pref value to the label of a video codec choice. */ +internal fun videoCodecLabel(v: String): Int = CodecLabels[v] ?: R.string.video_codec_auto + +internal fun videoQualityLabel(v: String): Int = QualityLabels[v] ?: R.string.video_quality_normal + +private val CodecLabels = mapOf( + "auto" to R.string.video_codec_auto, "h264" to R.string.video_codec_h264, "hevc" to R.string.video_codec_hevc, +) + +private val QualityLabels = mapOf( + "normal" to R.string.video_quality_normal, "high" to R.string.video_quality_high, + "lossless" to R.string.video_quality_lossless, +) + +/** Video codec and quality choices as one segmented group; they apply on the next connection. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun VideoGroup(state: SettingsState, actions: SettingsActions, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + Column(modifier.fillMaxWidth().testTag("video_group"), verticalArrangement = Arrangement.spacedBy(2.dp)) { + VideoChoice( + tag = "video_codec", title = R.string.video_codec_title, desc = R.string.video_codec_desc, + values = VideoCodecs, labels = CodecLabels, selected = state.videoCodec, + enabled = { it != "hevc" || state.hevcAvailable }, onSelect = actions::setVideoCodec, + shape = segmentShape(0, 2), colors = colors, + ) + VideoChoice( + tag = "stream_quality", title = R.string.video_quality_title, desc = R.string.video_quality_desc, + values = StreamQualities, labels = QualityLabels, selected = state.streamQuality, + enabled = { true }, onSelect = actions::setStreamQuality, + shape = segmentShape(1, 2), colors = colors, + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun VideoChoice( + tag: String, + @StringRes title: Int, + @StringRes desc: Int, + values: List, + labels: Map, + selected: String, + enabled: (String) -> Boolean, + onSelect: (String) -> Unit, + shape: Shape, + colors: ColorScheme, +) { + Surface(shape = shape, color = colors.surfaceBright, modifier = Modifier.fillMaxWidth().testTag(tag)) { + Column(Modifier.padding(RowPadding), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween)) { + values.forEachIndexed { i, v -> + ToggleButton( + checked = selected == v, + onCheckedChange = { onSelect(v) }, + enabled = enabled(v), + shapes = when (i) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + values.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + modifier = Modifier.weight(1f).semantics { role = Role.RadioButton }.testTag("${tag}_$v"), + ) { + Text(stringResource(labels.getValue(v)), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + Text(stringResource(desc), style = MaterialTheme.typography.bodySmall, color = colors.onSurfaceVariant) + } + } +} + +/** Wide page version: one row with the current choices that opens [VideoGroup] in a dialog, so the page does not scroll. */ +@Composable +fun VideoSummary(state: SettingsState, actions: SettingsActions, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + var open by rememberSaveable { mutableStateOf(false) } + Surface( + onClick = { open = true }, + shape = RoundedCornerShape(GroupEnd), color = colors.surfaceBright, + modifier = modifier.fillMaxWidth().testTag("video_summary"), + ) { + Row( + Modifier.heightIn(min = RowMinHeight).padding(RowPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Symbol(R.drawable.ic_tune, tint = colors.secondary) + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.section_video), + style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, + ) + Text( + stringResource( + R.string.video_summary, + stringResource(videoCodecLabel(state.videoCodec)), stringResource(videoQualityLabel(state.streamQuality)), + ), + style = MaterialTheme.typography.bodySmall, color = colors.onSurfaceVariant, + ) + } + Symbol(R.drawable.ic_chevron_right, tint = colors.primary) + } + } + if (open) { + Dialog(onDismissRequest = { open = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface( + shape = RoundedCornerShape(28.dp), color = colors.surfaceContainer, + modifier = Modifier.widthIn(max = 560.dp).fillMaxWidth(0.9f).testTag("video_dialog"), + ) { + Column(Modifier.verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.section_video), Modifier.weight(1f), + style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, + ) + TextButton(onClick = { open = false }, modifier = Modifier.testTag("video_dialog_close")) { + Text(stringResource(R.string.wifi_manage_close)) + } + } + VideoGroup(state, actions) + } + } + } + } +} + // ---------------------------------------------------------------- Zoom, palm, air actions /** Zoom level buttons and double-click time slider as one segmented group. */ diff --git a/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/SettingsScreen.kt index 1d78e66..e96bf08 100644 --- a/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/SettingsScreen.kt @@ -56,7 +56,7 @@ import com.librestatic.ezdisplay.InputPrefs import com.librestatic.ezdisplay.R /** Settings sections; AirActions only exists while its card is shown (and is always last). */ -enum class SettingsSection { Pen, Zoom, Touch, Connection, AirActions } +enum class SettingsSection { Pen, Zoom, Touch, Video, Connection, AirActions } private fun airCardShown(state: SettingsState) = state.airActions == AirActions.On || state.airActions == AirActions.Unknown @@ -179,6 +179,8 @@ private fun PhoneMain(state: SettingsState, actions: SettingsActions, onOpenEdit SectionTitle(stringResource(R.string.section_touch)) PalmRow(state.palmRejection, actions::setPalmRejection) AirActionsCard(state.airActions, Modifier.padding(top = profileGap(2))) + SectionTitle(stringResource(R.string.section_video)) + VideoGroup(state, actions) SectionTitle(stringResource(R.string.section_connection)) ConnectionContent(state, actions) } @@ -229,7 +231,8 @@ private fun ListDetailLayout( ) { val colors = MaterialTheme.colorScheme val sections = buildList { - add(SettingsSection.Pen); add(SettingsSection.Zoom); add(SettingsSection.Touch); add(SettingsSection.Connection) + add(SettingsSection.Pen); add(SettingsSection.Zoom); add(SettingsSection.Touch); add(SettingsSection.Video) + add(SettingsSection.Connection) if (airCardShown(state)) add(SettingsSection.AirActions) } val current = selected.takeIf { it in sections } ?: SettingsSection.Pen @@ -288,6 +291,7 @@ private fun ListDetailLayout( ) SettingsSection.Zoom -> ZoomGroup(state, actions) SettingsSection.Touch -> PalmRow(state.palmRejection, actions::setPalmRejection) + SettingsSection.Video -> VideoGroup(state, actions) SettingsSection.Connection -> ConnectionContent(state, actions) SettingsSection.AirActions -> AirActionsCard(state.airActions) } @@ -300,6 +304,7 @@ private fun sectionTitle(s: SettingsSection) = when (s) { SettingsSection.Pen -> R.string.settings_profile SettingsSection.Zoom -> R.string.section_zoom SettingsSection.Touch -> R.string.settings_palm_title + SettingsSection.Video -> R.string.section_video SettingsSection.Connection -> R.string.section_connection SettingsSection.AirActions -> R.string.section_air_actions } @@ -308,6 +313,7 @@ private fun sectionIcon(s: SettingsSection) = when (s) { SettingsSection.Pen -> R.drawable.ic_stylus SettingsSection.Zoom -> R.drawable.ic_zoom_in SettingsSection.Touch -> R.drawable.ic_back_hand + SettingsSection.Video -> R.drawable.ic_tune SettingsSection.Connection -> R.drawable.ic_wifi SettingsSection.AirActions -> R.drawable.ic_info } @@ -317,6 +323,9 @@ private fun sectionValue(s: SettingsSection, state: SettingsState): String = whe SettingsSection.Pen -> stringResource(Profiles.firstOrNull { it.value == state.profile }?.title ?: R.string.profile_raw) SettingsSection.Zoom -> stringResource(R.string.settings_zoom_summary, formatZoom(state.zoomFactor), state.doubleClickMs) SettingsSection.Touch -> stringResource(if (state.palmRejection) R.string.settings_on else R.string.settings_off) + SettingsSection.Video -> stringResource( + R.string.video_summary, stringResource(videoCodecLabel(state.videoCodec)), stringResource(videoQualityLabel(state.streamQuality)), + ) SettingsSection.Connection -> connectionSummary(state) SettingsSection.AirActions -> stringResource(R.string.section_air_actions_value) } @@ -422,6 +431,7 @@ private fun WideLayout( WideHeading(stringResource(R.string.section_touch)) PalmRow(state.palmRejection, actions::setPalmRejection) AirActionsCard(state.airActions, Modifier.padding(top = profileGap(3))) + VideoSummary(state, actions, Modifier.padding(top = profileGap(3))) } Column(Modifier.weight(1f)) { WideHeading(stringResource(R.string.section_connection)) @@ -436,6 +446,8 @@ private fun WideLayout( PalmRow(state.palmRejection, actions::setPalmRejection) AirActionsCard(state.airActions, Modifier.padding(top = profileGap(2))) Spacer(Modifier.height(20.dp)) + VideoSummary(state, actions) + Spacer(Modifier.height(20.dp)) WideHeading(stringResource(R.string.section_connection)) ConnectionSummary(state, actions) } diff --git a/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/SettingsStore.kt b/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/SettingsStore.kt index 4fdb899..236e43f 100644 --- a/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/SettingsStore.kt +++ b/android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/SettingsStore.kt @@ -9,6 +9,7 @@ import android.provider.Settings import androidx.core.content.edit import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf +import com.librestatic.ezdisplay.Capabilities import com.librestatic.ezdisplay.InputPrefs import com.librestatic.ezdisplay.InputPrefs.KeyCombo import com.librestatic.ezdisplay.R @@ -116,6 +117,12 @@ val KeyPresets = listOf( KeyPreset(R.string.keys_preset_ctrl_shift_z, KeyCombo(InputPrefs.MOD_CTRL or InputPrefs.MOD_SHIFT, 0x1D)), ) +/** Video codec choices (`stream_prefs.codec`) in display order. */ +val VideoCodecs = listOf("auto", "h264", "hevc") + +/** Quality choices (`stream_prefs.quality`) in display order. */ +val StreamQualities = listOf("normal", "high", "lossless") + /** Everything the settings screen shows. Defaults match what [InputPrefs.toJson] uses for unset prefs. */ data class SettingsState( val profile: String = "raw", @@ -129,6 +136,12 @@ data class SettingsState( val airActions: AirActions = AirActions.NotSamsung, /** Connect over Wi-Fi when the USB connection is not there. */ val wifiEnabled: Boolean = false, + /** Preferred video codec: one of [VideoCodecs]. */ + val videoCodec: String = "auto", + /** Quality over USB: one of [StreamQualities]. */ + val streamQuality: String = "normal", + /** Whether this device has an HEVC decoder; the HEVC choice is disabled without one. */ + val hevcAvailable: Boolean = true, val wifiHosts: List = emptyList(), /** Hosts seen on the network right now (only while discovery runs). */ val discovered: List = emptyList(), @@ -217,6 +230,8 @@ interface SettingsActions { fun setDoubleClickMs(ms: Int) fun setPalmRejection(on: Boolean) fun setWifiEnabled(on: Boolean) + fun setVideoCodec(codec: String) + fun setStreamQuality(quality: String) /** Opens the PIN dialog for a host that was found or typed. */ fun startPairing(name: String, shortFp: String, addr: String) @@ -236,6 +251,7 @@ class SettingsStore( airActions: AirActions, private val wifi: WifiBackend = WifiBackend.None, private val deviceName: String = Build.MODEL, + private val hevcAvailable: Boolean = true, /** Runs the blocking pairing; replaced by a direct executor in tests. */ private val io: Executor = Executors.newSingleThreadExecutor(), ) : SettingsActions { @@ -253,6 +269,9 @@ class SettingsStore( palmRejection = prefs.getBoolean(InputPrefs.PALM_REJECTION, true), airActions = airActions, wifiEnabled = prefs.getBoolean(WIFI_ENABLED, false), + videoCodec = prefs.getString(VIDEO_CODEC, null).takeIf { it in VideoCodecs } ?: "auto", + streamQuality = prefs.getString(STREAM_QUALITY, null).takeIf { it in StreamQualities } ?: "normal", + hevcAvailable = hevcAvailable, wifiHosts = wifi.hosts(), deviceFingerprint = wifi.identity(), ) @@ -316,6 +335,19 @@ class SettingsStore( _state.value = _state.value.copy(wifiEnabled = on) } + /** Ignored for an unknown codec, and for HEVC on a device without an HEVC decoder. */ + override fun setVideoCodec(codec: String) { + if (codec !in VideoCodecs || (codec == "hevc" && !hevcAvailable)) return + prefs.edit { putString(VIDEO_CODEC, codec) } + _state.value = _state.value.copy(videoCodec = codec) + } + + override fun setStreamQuality(quality: String) { + if (quality !in StreamQualities) return + prefs.edit { putString(STREAM_QUALITY, quality) } + _state.value = _state.value.copy(streamQuality = quality) + } + /** Hosts seen on the network changed. */ fun setDiscovered(hosts: List) { _state.value = _state.value.copy(discovered = hosts) @@ -381,8 +413,13 @@ class SettingsStore( /** Pref key of the Wi-Fi switch, read by MainActivity. */ const val WIFI_ENABLED = "wifi_enabled" + /** Pref keys of the video choices, read by `Capabilities` (applied on the next connection). */ + const val VIDEO_CODEC = "video_codec" + const val STREAM_QUALITY = "stream_quality" + fun create(context: Context) = SettingsStore( InputPrefs.prefs(context), readAirActions(context), NativeWifiBackend(context), + hevcAvailable = Capabilities.hasDecoder("hevc"), ) } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 1de13cd..eadac95 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -167,4 +167,22 @@ +%1$d weiteres +%1$d weitere + + Video + Video-Codec + Automatisch + Automatisch wählt den besten Codec, den dieses Tablet und der PC unterstützen. Änderungen gelten ab der nächsten Verbindung. + Qualität über USB + Normal + Hoch + Verlustfrei + Hoch ist eine feste, nahezu verlustfreie Qualität und braucht mehr Daten. Verlustfrei ist verlustfreies HEVC (4:2:0): die meisten Daten, möglicherweise mehr Latenz. Beides gilt nur über USB; WLAN nutzt immer Normal. + normal + hoch + verlustfrei + Qualität: %1$s + Qualität: %1$s angefordert · %2$s angewendet + Codec: %1$s angefordert + verlustfrei + %1$s · %2$s diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index b6ebeee..3b4df7f 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -170,4 +170,22 @@ +%1$d más +%1$d más + + Video + Códec de video + Automático + Automático elige el mejor códec que admitan tanto esta tablet como la PC. Los cambios se aplican en la próxima conexión. + Calidad por USB + Normal + Alta + Sin pérdida + Alta es una calidad fija casi sin pérdida y usa más datos. Sin pérdida es HEVC sin pérdida (4:2:0): la mayor cantidad de datos y puede aumentar la latencia. Ambas solo se aplican por USB; el Wi-Fi siempre usa Normal. + normal + alta + sin pérdida + Calidad: %1$s + Calidad: %1$s solicitada · %2$s aplicada + Códec: %1$s solicitado + sin pérdida + %1$s · %2$s diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index a843b8e..3140218 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -170,4 +170,22 @@ +%1$d de plus +%1$d de plus + + Vidéo + Codec vidéo + Automatique + Automatique choisit le meilleur codec pris en charge par cette tablette et par le PC. Les changements s\'appliquent à la prochaine connexion. + Qualité via USB + Normale + Élevée + Sans perte + Élevée est une qualité fixe quasi sans perte et utilise plus de données. Sans perte est du HEVC sans perte (4:2:0) : le plus de données, avec une latence parfois accrue. Les deux ne s\'appliquent que via USB ; le Wi-Fi utilise toujours Normale. + normale + élevée + sans perte + Qualité : %1$s + Qualité : %1$s demandée · %2$s appliquée + Codec : %1$s demandé + sans perte + %1$s · %2$s diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 01e2150..4c7867d 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -170,4 +170,22 @@ +%1$d altri +%1$d altri + + Video + Codec video + Automatico + Automatico sceglie il miglior codec supportato sia da questo tablet sia dal PC. Le modifiche valgono dalla prossima connessione. + Qualità via USB + Normale + Alta + Senza perdite + Alta è una qualità fissa quasi senza perdite e usa più dati. Senza perdite è HEVC senza perdite (4:2:0): il massimo dei dati, con possibile aumento della latenza. Entrambe valgono solo via USB; il Wi-Fi usa sempre Normale. + normale + alta + senza perdite + Qualità: %1$s + Qualità: %1$s richiesta · %2$s applicata + Codec: %1$s richiesto + senza perdite + %1$s · %2$s diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml index 93ffbb4..8ab1719 100644 --- a/android/app/src/main/res/values-pt/strings.xml +++ b/android/app/src/main/res/values-pt/strings.xml @@ -170,4 +170,22 @@ +%1$d a mais +%1$d a mais + + Vídeo + Codec de vídeo + Automático + Automático escolhe o melhor codec compatível com este tablet e com o PC. As alterações valem a partir da próxima conexão. + Qualidade via USB + Normal + Alta + Sem perdas + Alta é uma qualidade fixa quase sem perdas e usa mais dados. Sem perdas é HEVC sem perdas (4:2:0): o máximo de dados, com possível aumento de latência. Ambas só valem via USB; o Wi-Fi sempre usa Normal. + normal + alta + sem perdas + Qualidade: %1$s + Qualidade: %1$s solicitada · %2$s aplicada + Codec: %1$s solicitado + sem perdas + %1$s · %2$s diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 00fc2c3..5260874 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -167,4 +167,24 @@ +%1$d more +%1$d more + + Video + Video codec + Automatic + H.264 + HEVC + Automatic picks the best codec that both this tablet and the PC support. Changes apply on the next connection. + Quality over USB + Normal + High + Lossless + High is near-lossless fixed quality and uses more data. Lossless is HEVC lossless (4:2:0): the most data, and it may raise latency. Both only apply over USB; Wi-Fi always uses Normal. + normal + high + lossless + Quality: %1$s + Quality: %1$s requested · %2$s applied + Codec: %1$s requested + lossless + %1$s · %2$s diff --git a/android/app/src/test/kotlin/com/librestatic/ezdisplay/StreamOsdTest.kt b/android/app/src/test/kotlin/com/librestatic/ezdisplay/StreamOsdTest.kt index 0d4c436..709a3bc 100644 --- a/android/app/src/test/kotlin/com/librestatic/ezdisplay/StreamOsdTest.kt +++ b/android/app/src/test/kotlin/com/librestatic/ezdisplay/StreamOsdTest.kt @@ -104,6 +104,45 @@ class StreamOsdTest { assertTrue(c.headline, c.headline.contains("tasa de bits variable, calidad 19")) } + private val losslessReport = """{"wanted_kbps":46000,"user_bitrate":false,"ceiling_kbps":0, + "attempts":[{"encoder":"hevc_nvenc","options":"tune=lossless","ok":false,"error":"Function not implemented"}, + {"encoder":"hevc_nvenc","options":"rc=constqp qp=10","ok":true,"error":""}], + "rate_mode":"cqp","quality":10,"target_kbps":46000,"max_kbps":0, + "wanted_quality":"lossless","applied_quality":"high","wanted_codec":"hevc"}""" + + @Test fun qualityLineShowsRequestedAndApplied() { + val j = status(losslessReport).put("codec", "hevc") + val c = StreamOsd.render(res, StreamOsd.parse(j)) + assertEquals("Quality: lossless requested · high applied", c.lines[1]) + assertEquals("HEVC · constant quality 10", c.headline) + } + + @Test fun qualityLineWhenAppliedAsWanted() { + val r = losslessReport.replace("\"applied_quality\":\"high\"", "\"applied_quality\":\"lossless\"") + .replace("\"cqp\"", "\"lossless\"") + val c = StreamOsd.render(res, StreamOsd.parse(status(r).put("codec", "hevc"))) + assertEquals("Quality: lossless", c.lines[1]) + assertEquals("HEVC · lossless", c.headline) + } + + @Test fun codecLineOnlyWhenTheFinalCodecDiffers() { + val c = StreamOsd.render(res, StreamOsd.parse(status(losslessReport))) + assertTrue(c.lines.toString(), c.lines.contains("Codec: HEVC requested")) + val same = StreamOsd.render(res, StreamOsd.parse(status(losslessReport).put("codec", "hevc"))) + assertFalse(same.lines.toString(), same.lines.any { it.startsWith("Codec:") }) + } + + @Test fun missingNewKeysAddNoLines() { + val c = StreamOsd.render(res, StreamOsd.parse(status(autoReport))) + assertEquals(3, c.lines.size) + assertFalse(c.lines.any { it.startsWith("Quality:") || it.startsWith("Codec:") }) + } + + @Test @Config(qualifiers = "es") fun spanishQualityLine() { + val c = StreamOsd.render(res, StreamOsd.parse(status(losslessReport).put("codec", "hevc"))) + assertEquals("Calidad: sin pérdida solicitada · alta aplicada", c.lines[1]) + } + @Test fun viewIsNotInteractive() { val v = StreamOsdView(context) assertFalse(v.isClickable) @@ -121,4 +160,15 @@ class StreamOsdTest { assertEquals(View.VISIBLE, osd.visibility) root.captureRoboImage("build/outputs/roborazzi/stream_osd.png") } + + @Test @Config(qualifiers = "w1280dp-h800dp-notnight") + fun captureOsdLossless() { + val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get() + val root = FrameLayout(activity).apply { setBackgroundColor(Color.rgb(40, 60, 90)) } + val osd = StreamOsdView(activity) + root.addView(osd, FrameLayout.LayoutParams(-2, -2, Gravity.BOTTOM or Gravity.START).apply { setMargins(32, 32, 32, 32) }) + activity.setContentView(root) + osd.show(StreamOsd.render(activity.resources, StreamOsd.parse(status(losslessReport).put("codec", "hevc")))) + root.captureRoboImage("build/outputs/roborazzi/stream_osd_lossless.png") + } } diff --git a/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsPhoneTest.kt b/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsPhoneTest.kt index 4aa66c2..cb3f6b6 100644 --- a/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsPhoneTest.kt +++ b/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsPhoneTest.kt @@ -3,6 +3,7 @@ package com.librestatic.ezdisplay.settings import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.hasClickAction @@ -48,6 +49,15 @@ class SettingsPhoneTest : SettingsUiTestBase() { assertEquals("desktop", store.state.value.profile) } + @Test fun videoChoicesSaveCodecAndQuality() { + launch() + tag("video_codec_hevc").performScrollTo().performClick() + tag("stream_quality_lossless").performScrollTo().performClick() + assertEquals("hevc", prefs.getString(SettingsStore.VIDEO_CODEC, null)) + assertEquals("lossless", prefs.getString(SettingsStore.STREAM_QUALITY, null)) + tag("stream_quality_lossless").assertIsOn() + } + @Test fun selectedProfileShowsDescriptionAndMarker() { launch { putString(InputPrefs.PROFILE, "drawing") } rule.onNodeWithText(str(R.string.profile_drawing_desc)).assertExists() diff --git a/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsScreenshotTest.kt b/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsScreenshotTest.kt index 888e0eb..2ff9732 100644 --- a/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsScreenshotTest.kt +++ b/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsScreenshotTest.kt @@ -42,6 +42,8 @@ class SettingsScreenshotTest { override fun setDoubleClickMs(ms: Int) {} override fun setPalmRejection(on: Boolean) {} override fun setWifiEnabled(on: Boolean) {} + override fun setVideoCodec(codec: String) {} + override fun setStreamQuality(quality: String) {} override fun startPairing(name: String, shortFp: String, addr: String) {} override fun startPairing(link: PairLink) {} override fun submitPairing(pin: String) {} @@ -95,6 +97,14 @@ class SettingsScreenshotTest { deviceFingerprint = "abcd-0123-4567-89ef", ) + @Test @Config(sdk = [36], qualifiers = "w412dp-h915dp-night") + fun phone_video_dark() { + show(SettingsState(videoCodec = "hevc", streamQuality = "lossless")) + rule.onNodeWithTag("video_group").performScrollTo() + rule.waitForIdle() + capture("phone_video_dark") + } + @Test @Config(sdk = [36], qualifiers = "w412dp-h915dp-night") fun phone_connection_dark() { show(connectionState) diff --git a/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsStoreTest.kt b/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsStoreTest.kt index ced37e6..8d5051b 100644 --- a/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsStoreTest.kt +++ b/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsStoreTest.kt @@ -3,6 +3,7 @@ package com.librestatic.ezdisplay.settings import android.content.Context import android.content.SharedPreferences import androidx.test.core.app.ApplicationProvider +import com.librestatic.ezdisplay.Capabilities import com.librestatic.ezdisplay.InputPrefs import com.librestatic.ezdisplay.InputPrefs.KeyCombo import org.json.JSONObject @@ -268,4 +269,44 @@ class SettingsStoreTest { assertEquals("raw", JSONObject(InputPrefs.toJson(context)).getString("profile")) assertEquals("raw", SettingsStore(prefs, AirActions.Off).state.value.profile) } + + @Test fun videoPrefsDefaultToAutoAndNormal() { + assertEquals("auto", s.videoCodec) + assertEquals("normal", s.streamQuality) + assertEquals("auto", Capabilities.streamPrefs(prefs, hevcAvailable = true).getString("codec")) + assertEquals("normal", Capabilities.streamPrefs(prefs, hevcAvailable = true).getString("quality")) + } + + @Test fun videoPrefsPersistAndReachTheCapabilities() { + store.setVideoCodec("hevc") + store.setStreamQuality("lossless") + assertEquals("hevc", s.videoCodec) + assertEquals("lossless", s.streamQuality) + val reread = SettingsStore(prefs, AirActions.Off).state.value + assertEquals("hevc", reread.videoCodec) + assertEquals("lossless", reread.streamQuality) + val j = Capabilities.streamPrefs(prefs, hevcAvailable = true) + assertEquals("hevc", j.getString("codec")) + assertEquals("lossless", j.getString("quality")) + } + + @Test fun invalidVideoValuesAreIgnored() { + store.setVideoCodec("av1") + store.setStreamQuality("ultra") + assertEquals("auto", s.videoCodec) + assertEquals("normal", s.streamQuality) + prefs.edit().putString(SettingsStore.VIDEO_CODEC, "bogus").putString(SettingsStore.STREAM_QUALITY, "x").commit() + val j = Capabilities.streamPrefs(prefs, hevcAvailable = true) + assertEquals("auto", j.getString("codec")) + assertEquals("normal", j.getString("quality")) + } + + @Test fun hevcIsRefusedWithoutADecoder() { + val noHevc = SettingsStore(prefs, AirActions.Off, hevcAvailable = false) + noHevc.setVideoCodec("hevc") + assertEquals("auto", noHevc.state.value.videoCodec) + assertFalse(noHevc.state.value.hevcAvailable) + prefs.edit().putString(SettingsStore.VIDEO_CODEC, "hevc").commit() + assertEquals("auto", Capabilities.streamPrefs(prefs, hevcAvailable = false).getString("codec")) + } } diff --git a/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsWideTest.kt b/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsWideTest.kt index be9b4a6..8e64bc1 100644 --- a/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsWideTest.kt +++ b/android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsWideTest.kt @@ -124,6 +124,17 @@ class SettingsWideTest : SettingsUiTestBase() { } } + @Test fun videoRowOpensADialogWithTheChoices() { + launch() + tag("video_group").assertDoesNotExist() + tag("video_summary").performClick() + tag("video_dialog").assertExists() + tag("stream_quality_high").performClick() + assertEquals("high", prefs.getString(SettingsStore.STREAM_QUALITY, null)) + tag("video_dialog_close").performClick() + tag("video_dialog").assertDoesNotExist() + } + @Test fun airCardSitsBelowThePalmRow() { launch(AirActions.On) { putString(InputPrefs.PROFILE, "graphics_tablet") } val palm = bounds("palm_row") diff --git a/crates/ezd-android/src/caps.rs b/crates/ezd-android/src/caps.rs index 37faa2b..a3755bd 100644 --- a/crates/ezd-android/src/caps.rs +++ b/crates/ezd-android/src/caps.rs @@ -9,11 +9,17 @@ //! "low_latency": true, "max_width": 4096, "max_height": 4096, "max_fps": 240, "hdr10": false}], //! "hdr": {"display_hdr10": true, "max_nits": 1000, "max_avg_nits": 600, "min_millinits": 0}, //! "stylus": true, -//! "max_touch_points": 10 +//! "max_touch_points": 10, +//! "stream_prefs": {"codec": "auto", "quality": "normal"} //! } //! ``` +//! +//! `stream_prefs` is optional: `codec` is `auto`, `h264`, `hevc` or `av1`, `quality` is `normal`, +//! `high` or `lossless`; a missing object or an unknown string means `auto` / `normal`. -use ezd_proto::{ClientCaps, ClientHdr, Codec, DecoderCaps, DisplayInfo}; +use ezd_proto::{ + ClientCaps, ClientHdr, ClientPrefs, Codec, DecoderCaps, DisplayInfo, QualityLevel, +}; use serde::Deserialize; /// Parsed capabilities plus the device name. @@ -25,6 +31,16 @@ pub struct ParsedCaps { pub caps: ClientCaps, /// HDR10 capabilities for `ClientConfig.hdr`; `None` when the JSON has no `hdr` object. pub hdr: Option, + /// Codec and quality for `ClientConfig.prefs`. + pub prefs: ClientPrefs, +} + +#[derive(Deserialize, Default)] +struct JsonPrefs { + #[serde(default)] + codec: String, + #[serde(default)] + quality: String, } #[derive(Deserialize)] @@ -38,6 +54,8 @@ struct JsonCaps { hdr: Option, #[serde(default)] stylus: bool, + #[serde(default)] + stream_prefs: Option, #[serde(default = "default_touch")] max_touch_points: u32, } @@ -101,6 +119,14 @@ fn parse_codec(s: &str) -> Option { } } +fn parse_quality(s: &str) -> QualityLevel { + match s.to_ascii_lowercase().as_str() { + "high" => QualityLevel::High, + "lossless" => QualityLevel::Lossless, + _ => QualityLevel::Normal, + } +} + /// Parses the Kotlin-provided capabilities JSON. Decoders of unknown codecs are skipped. pub fn parse_caps(json: &str) -> Result { let j: JsonCaps = serde_json::from_str(json).map_err(|e| format!("bad caps json: {e}"))?; @@ -143,8 +169,10 @@ pub fn parse_caps(json: &str) -> Result { &main10, ) }); + let prefs = j.stream_prefs.unwrap_or_default(); Ok(ParsedCaps { hdr, + prefs: ClientPrefs::new(parse_codec(&prefs.codec), parse_quality(&prefs.quality)), device_name: if j.device_name.is_empty() { "android".into() } else { @@ -282,6 +310,39 @@ mod tests { assert_eq!(h, ClientHdr::none()); } + #[test] + fn parses_stream_prefs() { + let with = |prefs: &str| { + let json = format!( + r#"{{"display": {{"width_px": 1, "height_px": 1, "refresh_rates_mhz": [60000]}}{prefs}}}"# + ); + parse_caps(&json).unwrap().prefs + }; + assert_eq!(with(""), ClientPrefs::auto()); + assert_eq!(with(r#", "stream_prefs": {}"#), ClientPrefs::auto()); + assert_eq!( + with(r#", "stream_prefs": {"codec": "hevc", "quality": "lossless"}"#), + ClientPrefs::new(Some(Codec::Hevc), QualityLevel::Lossless) + ); + assert_eq!( + with(r#", "stream_prefs": {"codec": "h264", "quality": "high"}"#), + ClientPrefs::new(Some(Codec::H264), QualityLevel::High) + ); + assert_eq!( + with(r#", "stream_prefs": {"codec": "av1"}"#), + ClientPrefs::new(Some(Codec::Av1), QualityLevel::Normal) + ); + // Unknown strings mean auto / normal. + assert_eq!( + with(r#", "stream_prefs": {"codec": "vp9", "quality": "ultra"}"#), + ClientPrefs::auto() + ); + assert_eq!( + with(r#", "stream_prefs": {"codec": "auto", "quality": "normal"}"#), + ClientPrefs::auto() + ); + } + #[test] fn rejects_missing_display() { assert!(parse_caps("{}").is_err()); diff --git a/crates/ezd-android/src/session.rs b/crates/ezd-android/src/session.rs index 4f83180..25992fc 100644 --- a/crates/ezd-android/src/session.rs +++ b/crates/ezd-android/src/session.rs @@ -422,6 +422,7 @@ fn run( let mut cfg = ClientConfig::new(caps.device_name, caps.caps); cfg.app_version = env!("CARGO_PKG_VERSION").into(); cfg.hdr = caps.hdr; + cfg.prefs = caps.prefs; let mut handler = Handler { shared: shared.clone(), dec: dec.clone(), diff --git a/crates/ezd-android/src/status.rs b/crates/ezd-android/src/status.rs index 36460d4..47c0b67 100644 --- a/crates/ezd-android/src/status.rs +++ b/crates/ezd-android/src/status.rs @@ -1,7 +1,7 @@ //! Session phase and the JSON returned by `pollStats()`. use ezd_client_core::ClientSnapshot; -use ezd_proto::{Codec, StreamConfig, StreamReport}; +use ezd_proto::{Codec, QualityLevel, StreamConfig, StreamReport}; use serde::Serialize; use std::sync::Mutex; @@ -161,6 +161,13 @@ impl Shared { "quality": r.rate.quality, "target_kbps": r.rate.target_kbps, "max_kbps": r.rate.max_kbps, + "wanted_quality": QualityLevel::from_code(r.wanted_quality).name(), + "applied_quality": QualityLevel::from_code(r.applied_quality).name(), + "wanted_codec": Codec::from_code(r.wanted_codec).map_or("auto", |c| match c { + Codec::H264 => "h264", + Codec::Hevc => "hevc", + Codec::Av1 => "av1", + }), }) }); let v = serde_json::json!({ @@ -256,6 +263,9 @@ mod tests { target_kbps: 45_949, max_kbps: 91_898, }, + wanted_quality: QualityLevel::Lossless.code(), + applied_quality: QualityLevel::High.code(), + wanted_codec: Codec::Hevc.code(), }); let v = j(&sh); assert_eq!(v["report_seq"], 1); @@ -269,6 +279,9 @@ mod tests { assert_eq!(r["attempts"][0]["error"], ""); assert_eq!(r["rate_mode"], "vbr"); assert_eq!(r["quality"], 19); + assert_eq!(r["wanted_quality"], "lossless"); + assert_eq!(r["applied_quality"], "high"); + assert_eq!(r["wanted_codec"], "hevc"); assert_eq!( (r["target_kbps"].as_u64(), r["max_kbps"].as_u64()), (Some(45_949), Some(91_898)) diff --git a/crates/ezd-client-core/src/session.rs b/crates/ezd-client-core/src/session.rs index bf639c9..9f79d94 100644 --- a/crates/ezd-client-core/src/session.rs +++ b/crates/ezd-client-core/src/session.rs @@ -3,8 +3,8 @@ use crate::{ClientHandle, now_us}; use ezd_link::{Link, MuxError, MuxReceiver, MuxSender}; use ezd_proto::{ - Bye, ClientCaps, ClientHdr, CursorShape, CursorState, FrameAck, Hello, Message, Ping, Pong, - Role, StreamColor, StreamConfig, StreamReport, VideoFrame, + Bye, ClientCaps, ClientHdr, ClientPrefs, CursorShape, CursorState, FrameAck, Hello, Message, + Ping, Pong, Role, StreamColor, StreamConfig, StreamReport, VideoFrame, }; use std::time::Duration; use tokio::time::{MissedTickBehavior, interval, timeout}; @@ -71,6 +71,9 @@ pub struct ClientConfig { /// `ClientHdr` is sent right after the host `Hello`; `None` sends [`ClientHdr::none`] /// (no HDR10), so the host always gets an answer and never waits for it in vain. pub hdr: Option, + /// Codec and quality asked for in `ClientPrefs`, sent right after `ClientHdr` whenever the + /// host speaks protocol 0.8. The default is [`ClientPrefs::auto`]. + pub prefs: ClientPrefs, /// Interval of `ClientStats` messages. pub stats_interval: Duration, /// Interval of client-initiated pings (clock sync). @@ -96,6 +99,7 @@ impl ClientConfig { app_version: env!("CARGO_PKG_VERSION").into(), caps, hdr: None, + prefs: ClientPrefs::auto(), stats_interval: Duration::from_secs(1), ping_interval: Duration::from_millis(500), max_duration: None, @@ -223,6 +227,7 @@ pub async fn run_client( } let my_minor_supports_hdr = my_hello.supports_hdr(); let my_minor_supports_keyboard = my_hello.supports_keyboard(); + let my_minor_supports_prefs = my_hello.supports_stream_prefs(); tx.send_control(Message::Hello(my_hello))?; tx.send_control(Message::ClientCaps(cfg.caps.clone()))?; let hello = wait_for(&mut rx, &tx, "Hello", wait, |m| match m { @@ -238,6 +243,9 @@ pub async fn run_client( cfg.hdr.clone().unwrap_or_else(ClientHdr::none), ))?; } + if hello.supports_stream_prefs() && my_minor_supports_prefs { + tx.send_control(Message::ClientPrefs(cfg.prefs))?; + } wait_for(&mut rx, &tx, "HostCaps", wait, |m| match m { Message::HostCaps(h) => Ok(h), o => Err(o), diff --git a/crates/ezd-fake-client/i18n/de/ezd-fake-client.ftl b/crates/ezd-fake-client/i18n/de/ezd-fake-client.ftl index cd9118d..70167f2 100644 --- a/crates/ezd-fake-client/i18n/de/ezd-fake-client.ftl +++ b/crates/ezd-fake-client/i18n/de/ezd-fake-client.ftl @@ -16,6 +16,8 @@ arg-connect-timeout = Versucht die TCP-Verbindung so viele Sekunden lang erneut. arg-max-seconds = Bricht nach dieser Anzahl von Sekunden ab. arg-input-script = Spielt einmal eine vorgefertigte Eingabesequenz ab, beginnend mit dem ersten Videobild: tap, drag, circle, touch2, scroll oder keys (Strg+Leertaste während eines Stiftstrichs gehalten; benötigt einen Host mit KEYS). arg-exit-after-script = Beendet kurz nach Ende des Eingabeskripts. +arg-codec = Beim Host angefragter Codec: auto, h264, hevc oder av1. Der Host nutzt ihn nur, wenn beide Seiten ihn unterstützen. +arg-quality = Beim Host angefragte Qualität: normal, high oder lossless. Der Host setzt high und lossless nur bei einer USB-Verbindung um; lossless benötigt HEVC. err-dump-frame = --dump-frame N muss eine Zahl sein err-connecting = Verbindung zu { $addr } diff --git a/crates/ezd-fake-client/i18n/en/ezd-fake-client.ftl b/crates/ezd-fake-client/i18n/en/ezd-fake-client.ftl index b79ba16..2b5171d 100644 --- a/crates/ezd-fake-client/i18n/en/ezd-fake-client.ftl +++ b/crates/ezd-fake-client/i18n/en/ezd-fake-client.ftl @@ -16,6 +16,8 @@ arg-connect-timeout = Keep retrying the TCP connection for this many seconds. arg-max-seconds = Abort after this many seconds. arg-input-script = Play a canned input sequence once, starting at the first video frame: tap, drag, circle, touch2, scroll, or keys (Ctrl+Space held during a pen stroke; needs a host with KEYS). arg-exit-after-script = Exit shortly after the input script finished. +arg-codec = Codec to ask the host for: auto, h264, hevc or av1. The host uses it only if both sides support it. +arg-quality = Quality to ask the host for: normal, high or lossless. The host honours high and lossless only on a USB link; lossless needs HEVC. err-dump-frame = --dump-frame N must be a number err-connecting = connecting to { $addr } diff --git a/crates/ezd-fake-client/i18n/es/ezd-fake-client.ftl b/crates/ezd-fake-client/i18n/es/ezd-fake-client.ftl index 01fc992..f98cfb0 100644 --- a/crates/ezd-fake-client/i18n/es/ezd-fake-client.ftl +++ b/crates/ezd-fake-client/i18n/es/ezd-fake-client.ftl @@ -16,6 +16,8 @@ arg-connect-timeout = Sigue reintentando la conexión TCP durante este número d arg-max-seconds = Aborta tras este número de segundos. arg-input-script = Reproduce una vez una secuencia de entrada predefinida, desde el primer fotograma de video: tap, drag, circle, touch2, scroll o keys (Ctrl+Espacio mantenido durante un trazo del lápiz; requiere un host con KEYS). arg-exit-after-script = Termina poco después de acabar el guion de entrada. +arg-codec = Códec que se le pide al host: auto, h264, hevc o av1. El host lo usa solo si ambos lo soportan. +arg-quality = Calidad que se le pide al host: normal, high o lossless. El host respeta high y lossless solo en un enlace USB; lossless requiere HEVC. err-dump-frame = --dump-frame N debe ser un número err-connecting = conectando con { $addr } diff --git a/crates/ezd-fake-client/i18n/fr/ezd-fake-client.ftl b/crates/ezd-fake-client/i18n/fr/ezd-fake-client.ftl index 52aee3a..0e2d7f6 100644 --- a/crates/ezd-fake-client/i18n/fr/ezd-fake-client.ftl +++ b/crates/ezd-fake-client/i18n/fr/ezd-fake-client.ftl @@ -16,6 +16,8 @@ arg-connect-timeout = Réessaie la connexion TCP pendant ce nombre de secondes. arg-max-seconds = Abandonne après ce nombre de secondes. arg-input-script = Joue une fois une séquence d'entrée prédéfinie, dès la première image vidéo : tap, drag, circle, touch2, scroll ou keys (Ctrl+Espace maintenu pendant un tracé du stylet ; nécessite un hôte avec KEYS). arg-exit-after-script = Quitte peu après la fin du script d'entrée. +arg-codec = Codec demandé à l'hôte : auto, h264, hevc ou av1. L'hôte ne l'utilise que si les deux côtés le prennent en charge. +arg-quality = Qualité demandée à l'hôte : normal, high ou lossless. L'hôte n'applique high et lossless que sur une liaison USB ; lossless nécessite HEVC. err-dump-frame = --dump-frame N doit être un nombre err-connecting = connexion à { $addr } diff --git a/crates/ezd-fake-client/i18n/it/ezd-fake-client.ftl b/crates/ezd-fake-client/i18n/it/ezd-fake-client.ftl index b34d8f5..2d526f5 100644 --- a/crates/ezd-fake-client/i18n/it/ezd-fake-client.ftl +++ b/crates/ezd-fake-client/i18n/it/ezd-fake-client.ftl @@ -16,6 +16,8 @@ arg-connect-timeout = Continua a ritentare la connessione TCP per questo numero arg-max-seconds = Interrompe dopo questo numero di secondi. arg-input-script = Riproduce una volta una sequenza di input predefinita, a partire dal primo fotogramma video: tap, drag, circle, touch2, scroll o keys (Ctrl+Spazio tenuto durante un tratto della penna; richiede un host con KEYS). arg-exit-after-script = Esce poco dopo la fine dello script di input. +arg-codec = Codec richiesto all'host: auto, h264, hevc o av1. L'host lo usa solo se entrambi i lati lo supportano. +arg-quality = Qualità richiesta all'host: normal, high o lossless. L'host applica high e lossless solo su un collegamento USB; lossless richiede HEVC. err-dump-frame = --dump-frame N deve essere un numero err-connecting = connessione a { $addr } diff --git a/crates/ezd-fake-client/i18n/pt/ezd-fake-client.ftl b/crates/ezd-fake-client/i18n/pt/ezd-fake-client.ftl index 590c698..3761cfb 100644 --- a/crates/ezd-fake-client/i18n/pt/ezd-fake-client.ftl +++ b/crates/ezd-fake-client/i18n/pt/ezd-fake-client.ftl @@ -16,6 +16,8 @@ arg-connect-timeout = Continua tentando a conexão TCP por este número de segun arg-max-seconds = Aborta após este número de segundos. arg-input-script = Reproduz uma vez uma sequência de entrada predefinida, a partir do primeiro quadro de vídeo: tap, drag, circle, touch2, scroll ou keys (Ctrl+Espaço mantido durante um traço da caneta; requer um host com KEYS). arg-exit-after-script = Sai logo após o término do roteiro de entrada. +arg-codec = Codec solicitado ao host: auto, h264, hevc ou av1. O host só o usa se os dois lados o suportarem. +arg-quality = Qualidade solicitada ao host: normal, high ou lossless. O host só respeita high e lossless em um link USB; lossless requer HEVC. err-dump-frame = --dump-frame N deve ser um número err-connecting = conectando a { $addr } diff --git a/crates/ezd-fake-client/src/lib.rs b/crates/ezd-fake-client/src/lib.rs index aed3bc9..fe248c8 100644 --- a/crates/ezd-fake-client/src/lib.rs +++ b/crates/ezd-fake-client/src/lib.rs @@ -19,8 +19,8 @@ use ezd_ffmpeg::{Decoder, FfmpegError}; use ezd_host_core::synthetic::read_frame_number; use ezd_link::{Link, MuxError}; use ezd_proto::{ - ClientCaps, ClientOptions, Codec, CursorShape, CursorState, DecoderCaps, DisplayInfo, - InputStatus, StreamConfig, StreamReport, VideoFrame, + ClientCaps, ClientOptions, ClientPrefs, Codec, CursorShape, CursorState, DecoderCaps, + DisplayInfo, InputStatus, QualityLevel, StreamConfig, StreamReport, VideoFrame, }; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -63,6 +63,8 @@ pub struct FakeClientConfig { pub proto_minor: Option, /// Hardware keyboard state to report with `ClientKeyboard` (`None` = never set). pub keyboard_attached: Option, + /// Codec and quality to ask the host for (`ClientPrefs`). + pub prefs: ClientPrefs, } impl Default for FakeClientConfig { @@ -84,6 +86,7 @@ impl Default for FakeClientConfig { stop_after_script: false, proto_minor: None, keyboard_attached: None, + prefs: ClientPrefs::auto(), } } } @@ -340,7 +343,13 @@ impl Handler { impl ClientHandler for Handler { fn on_stream_report(&mut self, report: &StreamReport) { - tracing::info!(?report, "host stream report"); + tracing::info!( + ?report, + wanted_quality = QualityLevel::from_code(report.wanted_quality).name(), + applied_quality = QualityLevel::from_code(report.applied_quality).name(), + wanted_codec = report.wanted_codec, + "host stream report" + ); } fn on_stream_config(&mut self, cfg: &StreamConfig) -> HandlerResult { @@ -426,6 +435,7 @@ pub async fn run_fake_client( core_cfg.app_version = env!("CARGO_PKG_VERSION").into(); core_cfg.proto_minor = cfg.proto_minor; + core_cfg.prefs = cfg.prefs; let handle = ClientHandle::new(); if let Some(a) = cfg.keyboard_attached { diff --git a/crates/ezd-fake-client/src/main.rs b/crates/ezd-fake-client/src/main.rs index 3e02f44..6d42381 100644 --- a/crates/ezd-fake-client/src/main.rs +++ b/crates/ezd-fake-client/src/main.rs @@ -6,6 +6,7 @@ use ezd_fake_client::fl; use ezd_fake_client::i18n; use ezd_fake_client::{FakeClientConfig, InputScript, run_fake_client}; use ezd_link::Link; +use ezd_proto::{ClientPrefs, Codec, QualityLevel}; use i18n_embed::fluent::FluentLanguageLoader; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -38,6 +39,10 @@ struct Args { input_script: Option, #[arg(long)] exit_after_script: bool, + #[arg(long, value_enum, default_value_t = CodecArg::Auto)] + codec: CodecArg, + #[arg(long, value_enum, default_value_t = QualityArg::Normal)] + quality: QualityArg, #[arg(long, hide = true)] proto_minor: Option, #[cfg(feature = "wifi")] @@ -64,6 +69,21 @@ enum ScriptArg { Keys, } +#[derive(Debug, Clone, Copy, ValueEnum)] +enum CodecArg { + Auto, + H264, + Hevc, + Av1, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum QualityArg { + Normal, + High, + Lossless, +} + /// The command line with every help text taken from `l`. fn command(l: &FluentLanguageLoader) -> Command { use i18n_embed_fl::fl; @@ -85,7 +105,9 @@ fn command(l: &FluentLanguageLoader) -> Command { .mut_arg("input_script", |a| a.help(fl!(l, "arg-input-script"))) .mut_arg("exit_after_script", |a| { a.help(fl!(l, "arg-exit-after-script")) - }); + }) + .mut_arg("codec", |a| a.help(fl!(l, "arg-codec"))) + .mut_arg("quality", |a| a.help(fl!(l, "arg-quality"))); #[cfg(feature = "wifi")] let cmd = cmd .mut_arg("wifi", |a| a.help(fl!(l, "arg-wifi"))) @@ -136,6 +158,19 @@ async fn main() -> Result<()> { }), stop_after_script: a.exit_after_script, proto_minor: a.proto_minor, + prefs: ClientPrefs::new( + match a.codec { + CodecArg::Auto => None, + CodecArg::H264 => Some(Codec::H264), + CodecArg::Hevc => Some(Codec::Hevc), + CodecArg::Av1 => Some(Codec::Av1), + }, + match a.quality { + QualityArg::Normal => QualityLevel::Normal, + QualityArg::High => QualityLevel::High, + QualityArg::Lossless => QualityLevel::Lossless, + }, + ), ..Default::default() }; diff --git a/crates/ezd-ffmpeg/src/encoder.rs b/crates/ezd-ffmpeg/src/encoder.rs index 86a42b4..285135d 100644 --- a/crates/ezd-ffmpeg/src/encoder.rs +++ b/crates/ezd-ffmpeg/src/encoder.rs @@ -2,6 +2,7 @@ use crate::color::{ColorInfo, HdrMeta, tag_frame}; use crate::error::{EAGAIN, FfmpegError, averror_eof, init_logging}; use crate::hw::HwDevice; use crate::options::option_sets_for; +use ezd_proto::QualityLevel; use ffmpeg_sys_next as ffi; use std::ffi::{CStr, CString}; use std::ptr; @@ -67,6 +68,8 @@ pub struct EncoderConfig { pub hdr: Option, /// Extra AVOptions applied on top of the preset (override it). pub extra_options: Vec<(String, String)>, + /// Quality level selecting the option sets (default [`QualityLevel::Normal`]). + pub quality: QualityLevel, } impl EncoderConfig { @@ -89,6 +92,7 @@ impl EncoderConfig { color: ColorInfo::default(), hdr: None, extra_options: Vec::new(), + quality: QualityLevel::Normal, } } } @@ -157,7 +161,7 @@ impl Encoder { ))); } let mut last = FfmpegError::msg("no option set"); - for set in option_sets_for(&cfg.name, cfg.pix_fmt == RawPixelFormat::P010) { + for set in option_sets_for(&cfg.name, cfg.pix_fmt == RawPixelFormat::P010, cfg.quality) { match Self::try_open(codec, cfg.clone(), &set) { Ok(e) => return Ok(e), Err(e) => { diff --git a/crates/ezd-ffmpeg/src/lib.rs b/crates/ezd-ffmpeg/src/lib.rs index 4e23719..00fff63 100644 --- a/crates/ezd-ffmpeg/src/lib.rs +++ b/crates/ezd-ffmpeg/src/lib.rs @@ -39,7 +39,7 @@ pub use probe::{ ProbeResult, encoder_available, probe_encoder, probe_encoder_with, software_encoders, }; pub use rate::{ - NVENC_CQ, PEAK_FACTOR, RateSettings, VAAPI_QP, VBV_FRAMES, X264_CRF, describe_rate, + HIGH_QP, NVENC_CQ, PEAK_FACTOR, RateSettings, VAAPI_QP, VBV_FRAMES, X264_CRF, describe_rate, options_label, rate_settings, }; pub use source::SwVideoSource; diff --git a/crates/ezd-ffmpeg/src/options.rs b/crates/ezd-ffmpeg/src/options.rs index f814a8c..cb4a4ab 100644 --- a/crates/ezd-ffmpeg/src/options.rs +++ b/crates/ezd-ffmpeg/src/options.rs @@ -1,16 +1,46 @@ -//! Low-latency option presets per encoder name. +//! Low-latency option presets per encoder name and quality level. + +use crate::rate::HIGH_QP; +use ezd_proto::QualityLevel; /// Ordered fallback chain of AVOption sets for `name`: the first set that /// opens successfully is used (e.g. VAAPI with `low_power` first, then without). -pub fn option_sets(name: &str) -> Vec> { - option_sets_for(name, false) +/// +/// [`QualityLevel::Normal`] is the rate-controlled preset sized to the link. +/// [`QualityLevel::High`] is a fixed quantiser of [`HIGH_QP`] (CRF for libx264). Lossless is the +/// `hevc_nvenc` lossless tune followed by the high sets, so a failed lossless open falls back to +/// high quality; every other encoder gets the high sets. +pub fn option_sets(name: &str, quality: QualityLevel) -> Vec> { + option_sets_for(name, false, quality) } /// Like [`option_sets`] for 10-bit input (P010): the HEVC encoders get `profile=main10`, and /// `hevc_vaapi` loses its `low_power` set (Gen 9.5 has no low-power HEVC Main10 and some /// drivers open it anyway). Other encoders are unchanged. -pub fn option_sets_for(name: &str, ten_bit: bool) -> Vec> { - let mut sets = base_option_sets(name); +pub fn option_sets_for( + name: &str, + ten_bit: bool, + quality: QualityLevel, +) -> Vec> { + let mut sets = match quality { + QualityLevel::Normal => normal_option_sets(name), + QualityLevel::High => high_option_sets(name), + QualityLevel::Lossless => { + let mut sets = Vec::new(); + if name == "hevc_nvenc" { + sets.push(vec![ + ("preset", "p1".to_string()), + ("tune", "lossless".to_string()), + ("zerolatency", "1".to_string()), + ("delay", "0".to_string()), + ("bf", "0".to_string()), + ("forced-idr", "1".to_string()), + ]); + } + sets.extend(high_option_sets(name)); + sets + } + }; if ten_bit && matches!(name, "hevc_nvenc" | "hevc_qsv" | "hevc_amf" | "hevc_vaapi") { if name == "hevc_vaapi" { sets.retain(|set| !set.iter().any(|(k, _)| *k == "low_power")); @@ -22,7 +52,7 @@ pub fn option_sets_for(name: &str, ten_bit: bool) -> Vec Vec> { +fn normal_option_sets(name: &str) -> Vec> { let s = |k: &'static str, v: &str| (k, v.to_string()); match name { "libx264" => vec![vec![ @@ -130,10 +160,86 @@ fn base_option_sets(name: &str) -> Vec> { } } +/// Near-transparent constant-quantiser sets. Encoders without a verified fixed-quantiser mode +/// keep their normal sets: QSV needs `AV_CODEC_FLAG_QSCALE` for CQP, which the encoder context +/// does not set, and its ICQ mode is not available on every driver. +fn high_option_sets(name: &str) -> Vec> { + let s = |k: &'static str, v: &str| (k, v.to_string()); + let qp = HIGH_QP.to_string(); + match name { + "libx264" => vec![vec![ + s("preset", "ultrafast"), + s("tune", "zerolatency"), + s("crf", &qp), + s("forced-idr", "1"), + s( + "x264-params", + "repeat-headers=1:scenecut=0:rc-lookahead=0:sliced-threads=1", + ), + ]], + "h264_nvenc" | "hevc_nvenc" | "av1_nvenc" => vec![vec![ + s("preset", "p1"), + s("tune", "ull"), + s("zerolatency", "1"), + s("delay", "0"), + s("bf", "0"), + s("rc", "constqp"), + s("qp", &qp), + s("forced-idr", "1"), + ]], + // The same low-power first order as the normal sets (see `normal_option_sets`). + "h264_vaapi" => [true, false] + .into_iter() + .map(|low_power| { + let mut v = vec![s("rc_mode", "CQP"), s("qp", &qp)]; + if low_power { + v.push(s("low_power", "1")); + } + v.push(s("async_depth", "1")); + v.push(s("level", "5.2")); + v + }) + .collect(), + "hevc_vaapi" | "av1_vaapi" => [true, false] + .into_iter() + .map(|low_power| { + let mut v = vec![s("rc_mode", "CQP"), s("qp", &qp)]; + if low_power { + v.push(s("low_power", "1")); + } + v.push(s("async_depth", "1")); + v + }) + .collect(), + // Fixed quantiser only: the sets without `rc` would run the driver's default rate control. + "h264_amf" | "hevc_amf" | "av1_amf" => { + let sets: [&[(&'static str, &str)]; 3] = [ + &[("usage", "ultralowlatency"), ("quality", "speed")], + &[("usage", "lowlatency"), ("quality", "speed")], + &[("quality", "speed")], + ]; + sets.iter() + .map(|set| { + let mut v: Vec<_> = set.iter().map(|&(k, val)| s(k, val)).collect(); + v.push(s("rc", "cqp")); + v.push(s("qp_i", &qp)); + v.push(s("qp_p", &qp)); + v.push(s("forced_idr", "1")); + v.push(s("async_depth", "1")); + v + }) + .collect() + } + _ => normal_option_sets(name), + } +} + #[cfg(test)] mod tests { use super::*; + const N: QualityLevel = QualityLevel::Normal; + #[test] fn every_known_encoder_has_a_preset() { for n in [ @@ -144,11 +250,11 @@ mod tests { "h264_amf", "libopenh264", ] { - let sets = option_sets(n); + let sets = option_sets(n, N); assert!(!sets.is_empty() && !sets[0].is_empty(), "{n}"); } let has = |n: &str, kv: (&'static str, &str)| { - option_sets(n) + option_sets(n, N) .iter() .any(|set| set.contains(&(kv.0, kv.1.to_string()))) }; @@ -165,12 +271,12 @@ mod tests { } assert!(has("h264_vaapi", ("qp", "20")) && !has("h264_vaapi", ("qp", "24"))); assert!(has("h264_amf", ("rc", "vbr_peak")) && !has("h264_amf", ("rc", "cbr"))); - assert_eq!(option_sets("unknown_enc"), vec![vec![]]); - assert_eq!(option_sets("h264_vaapi").len(), 3); - assert_eq!(option_sets("hevc_vaapi").len(), 2); - assert_eq!(option_sets("h264_amf").len(), 6); + assert_eq!(option_sets("unknown_enc", N), vec![vec![]]); + assert_eq!(option_sets("h264_vaapi", N).len(), 3); + assert_eq!(option_sets("hevc_vaapi", N).len(), 2); + assert_eq!(option_sets("h264_amf", N).len(), 6); assert!( - option_sets("h264_amf") + option_sets("h264_amf", N) .iter() .all(|set| set.contains(&("async_depth", "1".to_string()))) ); @@ -180,30 +286,116 @@ mod tests { fn ten_bit_sets_select_main10() { let main10 = ("profile", "main10".to_string()); for n in ["hevc_nvenc", "hevc_qsv", "hevc_amf", "hevc_vaapi"] { - let plain = option_sets_for(n, false); - assert_eq!(plain, option_sets(n), "{n}"); + let plain = option_sets_for(n, false, N); + assert_eq!(plain, option_sets(n, N), "{n}"); assert!(plain.iter().all(|set| !set.contains(&main10)), "{n}"); - let ten = option_sets_for(n, true); + let ten = option_sets_for(n, true, N); assert!( !ten.is_empty() && ten.iter().all(|set| set.contains(&main10)), "{n}" ); } // Counts: AMF and QSV keep their fallback chains; VAAPI drops low_power. - assert_eq!(option_sets_for("hevc_amf", true).len(), 6); - assert_eq!(option_sets_for("hevc_qsv", true).len(), 2); - assert_eq!(option_sets_for("hevc_vaapi", true).len(), 1); + assert_eq!(option_sets_for("hevc_amf", true, N).len(), 6); + assert_eq!(option_sets_for("hevc_qsv", true, N).len(), 2); + assert_eq!(option_sets_for("hevc_vaapi", true, N).len(), 1); assert!( - option_sets_for("hevc_vaapi", true) + option_sets_for("hevc_vaapi", true, N) .iter() .flatten() .all(|(k, _)| *k != "low_power") ); // Unrelated encoders ignore the flag. assert_eq!( - option_sets_for("h264_nvenc", true), - option_sets("h264_nvenc") + option_sets_for("h264_nvenc", true, N), + option_sets("h264_nvenc", N) + ); + assert_eq!( + option_sets_for("av1_vaapi", true, N), + option_sets("av1_vaapi", N) + ); + } + + fn has(sets: &[Vec<(&'static str, String)>], k: &str, v: &str) -> bool { + sets.iter() + .all(|set| set.contains(&(k_static(k), v.to_string()))) + } + + fn k_static(k: &str) -> &'static str { + [ + "rc", + "qp", + "tune", + "crf", + "rc_mode", + "qp_i", + "qp_p", + "low_power", + ] + .into_iter() + .find(|c| *c == k) + .unwrap() + } + + #[test] + fn high_sets_use_a_fixed_quantiser() { + let h = QualityLevel::High; + for n in ["h264_nvenc", "hevc_nvenc", "av1_nvenc"] { + let sets = option_sets(n, h); + assert_eq!(sets.len(), 1, "{n}"); + assert!(has(&sets, "rc", "constqp") && has(&sets, "qp", "10"), "{n}"); + assert!(has(&sets, "tune", "ull"), "{n}"); + assert!( + sets[0].contains(&("zerolatency", "1".into())) + && sets[0].contains(&("forced-idr", "1".into())) + ); + } + assert!(has(&option_sets("libx264", h), "crf", "10")); + for n in ["h264_vaapi", "hevc_vaapi", "av1_vaapi"] { + let sets = option_sets(n, h); + assert_eq!(sets.len(), 2, "{n}"); + assert!( + has(&sets, "rc_mode", "CQP") && has(&sets, "qp", "10"), + "{n}" + ); + assert!(sets[0].contains(&("low_power", "1".into())), "{n}"); + assert!(!sets[1].iter().any(|(k, _)| *k == "low_power"), "{n}"); + } + let amf = option_sets("h264_amf", h); + assert!(has(&amf, "rc", "cqp") && has(&amf, "qp_i", "10") && has(&amf, "qp_p", "10")); + assert!(amf.iter().all(|s| s.contains(&("async_depth", "1".into())))); + // QSV keeps its normal sets. + assert_eq!(option_sets("h264_qsv", h), option_sets("h264_qsv", N)); + // Ten-bit still selects Main10 for the fixed-quantiser sets. + let ten = option_sets_for("hevc_vaapi", true, h); + assert_eq!(ten.len(), 1); + assert!(ten[0].contains(&("profile", "main10".into()))); + } + + #[test] + fn lossless_is_an_hevc_nvenc_tune_with_high_fallback() { + let l = QualityLevel::Lossless; + let sets = option_sets("hevc_nvenc", l); + assert_eq!(sets.len(), 2); + assert!(sets[0].contains(&("tune", "lossless".into()))); + assert!( + !sets[0] + .iter() + .any(|(k, _)| matches!(*k, "rc" | "qp" | "cq")) ); - assert_eq!(option_sets_for("av1_vaapi", true), option_sets("av1_vaapi")); + for key in ["preset", "zerolatency", "delay", "bf", "forced-idr"] { + assert!(sets[0].iter().any(|(k, _)| *k == key), "{key}"); + } + assert_eq!(sets[1..], option_sets("hevc_nvenc", QualityLevel::High)[..]); + // Other encoders (including H.264 NVENC) have no lossless mode: the high sets. + for n in [ + "h264_nvenc", + "hevc_vaapi", + "libx264", + "hevc_amf", + "hevc_qsv", + ] { + assert_eq!(option_sets(n, l), option_sets(n, QualityLevel::High), "{n}"); + } } } diff --git a/crates/ezd-ffmpeg/src/rate.rs b/crates/ezd-ffmpeg/src/rate.rs index a7f4d63..f4e252c 100644 --- a/crates/ezd-ffmpeg/src/rate.rs +++ b/crates/ezd-ffmpeg/src/rate.rs @@ -10,6 +10,8 @@ pub const VBV_FRAMES: u32 = 4; pub const NVENC_CQ: u32 = 19; /// libx264 constant rate factor. pub const X264_CRF: u32 = 20; +/// Fixed quantiser (CRF for libx264) of the high-quality option sets. +pub const HIGH_QP: u32 = 10; /// Fixed quantiser of the VAAPI CQP fallback sets. pub const VAAPI_QP: u32 = 20; @@ -45,13 +47,15 @@ pub fn describe_rate(name: &str, options: &[(&str, String)], kbps: u32) -> RateC .map(|(_, v)| v.as_str()) }; let num = |key: &str| get(key).and_then(|v| v.parse::().ok()).unwrap_or(0); - let (mode, quality) = if let Some(crf) = get("crf") { + let (mode, quality) = if get("tune") == Some("lossless") { + (RateMode::Lossless, 0) + } else if let Some(crf) = get("crf") { (RateMode::Crf, crf.parse().unwrap_or(0)) } else if let Some(rc) = get("rc") { match rc { "cbr" => (RateMode::Cbr, 0), "vbr" | "vbr_peak" | "vbr_latency" => (RateMode::Vbr, num("cq")), - "cqp" => (RateMode::Cqp, num("qp")), + "cqp" | "constqp" => (RateMode::Cqp, num("qp").max(num("qp_i"))), _ => (RateMode::Unknown, 0), } } else if let Some(rc) = get("rc_mode") { @@ -71,8 +75,8 @@ pub fn describe_rate(name: &str, options: &[(&str, String)], kbps: u32) -> RateC mode: mode.code(), quality, target_kbps: kbps, - // A fixed quantiser ignores the rate limits. - max_kbps: if mode == RateMode::Cqp { + // A fixed quantiser and lossless ignore the rate limits. + max_kbps: if matches!(mode, RateMode::Cqp | RateMode::Lossless) { 0 } else { kbps.saturating_mul(PEAK_FACTOR) @@ -82,11 +86,12 @@ pub fn describe_rate(name: &str, options: &[(&str, String)], kbps: u32) -> RateC /// Compact `k=v` label of the interesting options of a set, joined by spaces. pub fn options_label(options: &[(&str, String)]) -> String { - const KEYS: [&str; 8] = [ + const KEYS: [&str; 9] = [ "rc", "rc_mode", "cq", "qp", + "qp_i", "crf", "low_power", "profile", @@ -94,7 +99,7 @@ pub fn options_label(options: &[(&str, String)]) -> String { ]; options .iter() - .filter(|(k, _)| KEYS.contains(k)) + .filter(|(k, v)| KEYS.contains(k) || (*k == "tune" && v == "lossless")) .map(|(k, v)| format!("{k}={v}")) .collect::>() .join(" ") @@ -104,6 +109,7 @@ pub fn options_label(options: &[(&str, String)]) -> String { mod tests { use super::*; use crate::option_sets; + use ezd_proto::QualityLevel; #[test] fn settings_use_peak_and_four_frame_vbv() { @@ -117,7 +123,7 @@ mod tests { #[test] fn describes_the_set_that_opened() { let d = |name: &str, i: usize| { - let sets = option_sets(name); + let sets = option_sets(name, QualityLevel::Normal); describe_rate(name, &sets[i], 1000) }; let nv = d("h264_nvenc", 0); @@ -134,9 +140,31 @@ mod tests { assert_eq!(d("libopenh264", 0).rate_mode(), RateMode::Unknown); } + #[test] + fn describes_high_and_lossless_sets() { + let sets = option_sets("hevc_nvenc", QualityLevel::Lossless); + let lossless = describe_rate("hevc_nvenc", &sets[0], 1000); + assert_eq!( + (lossless.rate_mode(), lossless.quality, lossless.max_kbps), + (RateMode::Lossless, 0, 0) + ); + assert_eq!(options_label(&sets[0]), "tune=lossless"); + let high = describe_rate("hevc_nvenc", &sets[1], 1000); + assert_eq!( + (high.rate_mode(), high.quality, high.max_kbps), + (RateMode::Cqp, 10, 0) + ); + let x = option_sets("libx264", QualityLevel::High); + let x = describe_rate("libx264", &x[0], 1000); + assert_eq!((x.rate_mode(), x.quality), (RateMode::Crf, 10)); + let amf = option_sets("hevc_amf", QualityLevel::High); + let amf = describe_rate("hevc_amf", &amf[0], 1000); + assert_eq!((amf.rate_mode(), amf.quality), (RateMode::Cqp, 10)); + } + #[test] fn label_keeps_only_interesting_keys() { - let sets = option_sets("h264_vaapi"); + let sets = option_sets("h264_vaapi", QualityLevel::Normal); assert_eq!( options_label(&sets[1]), "rc_mode=CQP qp=20 low_power=1 level=5.2" diff --git a/crates/ezd-host-core/src/session.rs b/crates/ezd-host-core/src/session.rs index cce08e9..53875cf 100644 --- a/crates/ezd-host-core/src/session.rs +++ b/crates/ezd-host-core/src/session.rs @@ -6,6 +6,7 @@ //! |<------------------- Hello --| //! |-- ClientCaps -------------->| //! |-- ClientHdr (minor >= 5) --->| the host waits for it when the client speaks minor >= 5 +//! |-- ClientPrefs (minor >= 8) ->| codec and quality the client asks for //! |<--------------- HostCaps ---| //! |<------------ StreamColor ---| minor >= 5 only //! |<------------- StreamConfig -| negotiate_hdr(ClientCaps, HostCaps, Prefs, ..) @@ -25,9 +26,10 @@ use crate::source::{CursorSink, SourceError, SourceHdr, VideoSource}; use crate::time::now_us; use ezd_link::{Link, MuxClosed, MuxError, MuxOptions, MuxReceiver, MuxSender, VideoCredit}; use ezd_proto::{ - Bye, ClientCaps, ClientHdr, ClientOptions, ClientStats, ClockSync, Codec, DEFAULT_CHUNK_LEN, - DynamicRange, Hello, HostCaps, InputStatus, Message, Ping, Pong, Prefs, ProtoError, Reject, - Role, StreamColor, StreamConfig, StreamReport, VideoFrame, negotiate_hdr, + Bye, ClientCaps, ClientHdr, ClientOptions, ClientPrefs, ClientStats, ClockSync, Codec, + DEFAULT_CHUNK_LEN, DynamicRange, Hello, HostCaps, InputStatus, Message, Ping, Pong, Prefs, + ProtoError, QualityLevel, RateMode, Reject, Role, StreamColor, StreamConfig, StreamReport, + VideoFrame, negotiate_hdr, }; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -290,6 +292,12 @@ pub async fn run_host_session_with( cfg, color, range: dynamic_range, + quality: + QualityPlan { + wanted: wanted_quality, + applied: applied_quality, + wanted_codec, + }, } = match setup { Ok(v) => v, Err(e) => { @@ -363,6 +371,9 @@ pub async fn run_host_session_with( }), user_bitrate: prefs.bitrate_kbps.is_some(), ceiling_kbps: ezd_proto::bitrate_ceiling_kbps(&prefs), + wanted_quality, + applied_quality, + wanted_codec, }); let input_for_video = input_tx.clone(); let handle = tokio::runtime::Handle::current(); @@ -372,6 +383,7 @@ pub async fn run_host_session_with( .spawn(move || { let mut source = source; source.set_dynamic_range(dynamic_range, &color); + source.set_quality(applied_quality); let cursor = CursorSink::new(tx.clone()); let r = match source.start(&cfg, cursor) { Ok(()) => { @@ -725,6 +737,18 @@ struct Negotiated { cfg: StreamConfig, color: StreamColor, range: DynamicRange, + quality: QualityPlan, +} + +/// What the client asked for and what the host applies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct QualityPlan { + /// Quality the client asked for (normal for a client without `ClientPrefs`). + wanted: QualityLevel, + /// Quality the host applies after its own rules. + applied: QualityLevel, + /// Codec the client prefers (`None` = auto). + wanted_codec: Option, } async fn handshake( @@ -768,11 +792,24 @@ async fn handshake( } else { None }; + // A client with minor >= 8 follows `ClientHdr` with its stream preferences. + let client_prefs: Option = if hello.supports_stream_prefs() { + Some( + expect(rx, tx, "ClientPrefs", wait, |m| match m { + Message::ClientPrefs(p) => Ok(p), + o => Err(o), + }) + .await?, + ) + } else { + None + }; tx.send_control(Message::HostCaps(host_caps.clone()))?; + let merged = prefs.with_client(client_prefs.as_ref()); let (cfg, range) = match negotiate_hdr( &caps, host_caps, - prefs, + &merged, hdr_in.source_hdr.is_some(), client_hdr.as_ref(), &hdr_in.main10, @@ -796,12 +833,18 @@ async fn handshake( o => Err(o), }) .await?; + let applied = merged.applied_quality(cfg.codec); Ok(Negotiated { hello, caps, cfg, color, range, + quality: QualityPlan { + wanted: client_prefs.map_or(QualityLevel::Normal, |p| p.quality()), + applied, + wanted_codec: client_prefs.and_then(|p| p.codec()), + }, }) } @@ -814,6 +857,9 @@ struct ReportBase { wanted_kbps: u32, user_bitrate: bool, ceiling_kbps: u32, + wanted_quality: QualityLevel, + applied_quality: QualityLevel, + wanted_codec: Option, } fn video_loop( @@ -867,6 +913,21 @@ fn video_loop( ceiling_kbps: report_base.as_ref().map_or(0, |b| b.ceiling_kbps), attempts: setup.attempts, rate: setup.rate, + wanted_quality: report_base.as_ref().map_or(0, |b| b.wanted_quality.code()), + applied_quality: report_base.as_ref().map_or(0, |b| { + // Lossless that did not open (the high sets ran) is reported as high. + match b.applied_quality { + QualityLevel::Lossless if setup.rate.rate_mode() != RateMode::Lossless => { + QualityLevel::High + } + q => q, + } + .code() + }), + wanted_codec: report_base + .as_ref() + .and_then(|b| b.wanted_codec) + .map_or(0, Codec::code), }; tracing::info!( wanted_kbps = report.wanted_kbps, @@ -874,6 +935,9 @@ fn video_loop( ceiling_kbps = report.ceiling_kbps, rate_mode = report.rate.rate_mode().name(), quality = report.rate.quality, + wanted_quality = report.wanted_quality, + applied_quality = report.applied_quality, + wanted_codec = report.wanted_codec, target_kbps = report.rate.target_kbps, max_kbps = report.rate.max_kbps, attempts = ?report.attempts, @@ -989,6 +1053,8 @@ mod tests { tx.send_control(Message::ClientCaps(caps())).unwrap(); tx.send_control(Message::ClientHdr(ClientHdr::none())) .unwrap(); + tx.send_control(Message::ClientPrefs(ClientPrefs::auto())) + .unwrap(); assert!(matches!(recv(&mut rx).await, Message::Hello(h) if h.role == Role::Host)); assert!(matches!(recv(&mut rx).await, Message::HostCaps(_))); assert_eq!( @@ -1134,6 +1200,8 @@ mod tests { tx.send_control(Message::ClientCaps(c)).unwrap(); tx.send_control(Message::ClientHdr(ClientHdr::none())) .unwrap(); + tx.send_control(Message::ClientPrefs(ClientPrefs::auto())) + .unwrap(); assert!(matches!(recv(&mut rx).await, Message::Hello(_))); assert!(matches!(recv(&mut rx).await, Message::HostCaps(_))); assert!(matches!(recv(&mut rx).await, Message::Reject(_))); @@ -1221,6 +1289,10 @@ mod tests { tx.send_control(Message::ClientCaps(c)).unwrap(); if let Some(h) = client_hdr { tx.send_control(Message::ClientHdr(h)).unwrap(); + if minor >= ezd_proto::MINOR_STREAM_PREFS { + tx.send_control(Message::ClientPrefs(ClientPrefs::auto())) + .unwrap(); + } } let mut seen = Vec::new(); loop { @@ -1247,9 +1319,13 @@ mod tests { inner: ScriptedSource, pending: bool, offered: bool, + quality: Arc>>, } impl VideoSource for ReportingSource { + fn set_quality(&mut self, quality: QualityLevel) { + *self.quality.lock().unwrap() = Some(quality); + } fn encoders(&self) -> Vec { self.inner.encoders() } @@ -1290,16 +1366,30 @@ mod tests { /// Messages a client of `minor` receives up to its first few video frames. async fn messages_until_video(minor: u16) -> Vec { + messages_with_prefs(minor, ClientPrefs::auto(), Prefs::default()) + .await + .0 + } + + /// Like [`messages_until_video`] with the client's preferences and the host's `Prefs`; also + /// returns the quality the source was told. + async fn messages_with_prefs( + minor: u16, + client_prefs: ClientPrefs, + prefs: Prefs, + ) -> (Vec, Option) { let (host_link, client_link) = Link::duplex_pair(1 << 20); + let told = Arc::new(std::sync::Mutex::new(None)); let src = ReportingSource { inner: ScriptedSource::new(Duration::from_millis(5), 3000), pending: false, offered: false, + quality: told.clone(), }; let host = tokio::spawn(run_host_session_with( host_link, Box::new(src), - Prefs::default(), + prefs, fast_opts(), )); let (tx, mut rx, _t) = client_link.into_mux(DEFAULT_CHUNK_LEN); @@ -1309,6 +1399,9 @@ mod tests { tx.send_control(Message::ClientCaps(caps())).unwrap(); tx.send_control(Message::ClientHdr(ClientHdr::none())) .unwrap(); + if minor >= ezd_proto::MINOR_STREAM_PREFS { + tx.send_control(Message::ClientPrefs(client_prefs)).unwrap(); + } loop { if matches!(recv(&mut rx).await, Message::StreamConfig(_)) { break; @@ -1325,12 +1418,13 @@ mod tests { tx.send_control(Message::Bye(Bye { reason: "t".into() })) .unwrap(); let _ = timeout(Duration::from_secs(10), host).await; - seen + let told = *told.lock().unwrap(); + (seen, told) } #[tokio::test] async fn report_precedes_the_first_frame_for_a_new_client() { - let seen = messages_until_video(ezd_proto::MINOR_REPORT).await; + let seen = messages_until_video(ezd_proto::MINOR_STREAM_PREFS).await; let report = seen .iter() .position(|m| matches!(m, Message::StreamReport(_))) @@ -1353,9 +1447,72 @@ mod tests { assert_eq!(count, 1, "one setup, one report"); } + fn report_of(seen: &[Message]) -> &StreamReport { + seen.iter() + .find_map(|m| match m { + Message::StreamReport(r) => Some(r), + _ => None, + }) + .expect("no StreamReport") + } + + #[tokio::test] + async fn client_quality_reaches_the_source_and_the_report() { + let high = ClientPrefs::new(Some(Codec::H264), QualityLevel::High); + let (seen, told) = + messages_with_prefs(ezd_proto::PROTOCOL_MINOR, high, Prefs::default()).await; + assert_eq!(told, Some(QualityLevel::High)); + let r = report_of(&seen); + assert_eq!( + (r.wanted_quality, r.applied_quality, r.wanted_codec), + (QualityLevel::High.code(), QualityLevel::High.code(), 1) + ); + } + + #[tokio::test] + async fn network_link_and_h264_downgrade_the_applied_quality() { + let lossless = ClientPrefs::new(None, QualityLevel::Lossless); + // The scripted client decodes H.264 only: lossless falls back to high. + let (seen, told) = + messages_with_prefs(ezd_proto::PROTOCOL_MINOR, lossless, Prefs::default()).await; + assert_eq!(told, Some(QualityLevel::High)); + let r = report_of(&seen); + assert_eq!( + (r.wanted_quality, r.applied_quality), + (QualityLevel::Lossless.code(), QualityLevel::High.code()) + ); + let net = Prefs { + link: ezd_proto::LinkKind::Network, + ..Prefs::default() + }; + let (seen, told) = messages_with_prefs(ezd_proto::PROTOCOL_MINOR, lossless, net).await; + assert_eq!(told, Some(QualityLevel::Normal)); + let r = report_of(&seen); + assert_eq!( + (r.wanted_quality, r.applied_quality), + (QualityLevel::Lossless.code(), QualityLevel::Normal.code()) + ); + } + + #[tokio::test] + async fn host_quality_overrides_the_client() { + let host = Prefs { + quality: QualityLevel::High, + ..Prefs::default() + }; + let (seen, told) = + messages_with_prefs(ezd_proto::PROTOCOL_MINOR, ClientPrefs::auto(), host).await; + assert_eq!(told, Some(QualityLevel::High)); + let r = report_of(&seen); + assert_eq!( + (r.wanted_quality, r.applied_quality), + (QualityLevel::Normal.code(), QualityLevel::High.code()) + ); + } + #[tokio::test] async fn old_client_gets_no_report() { - let seen = messages_until_video(ezd_proto::MINOR_REPORT - 1).await; + let seen = messages_until_video(ezd_proto::MINOR_STREAM_PREFS - 1).await; assert!(!seen.iter().any(|m| matches!(m, Message::StreamReport(_)))); } @@ -1457,6 +1614,8 @@ mod tests { tx.send_control(Message::ClientCaps(caps())).unwrap(); tx.send_control(Message::ClientHdr(ClientHdr::none())) .unwrap(); + tx.send_control(Message::ClientPrefs(ClientPrefs::auto())) + .unwrap(); for _ in 0..3 { recv(&mut rx).await; } @@ -1501,6 +1660,8 @@ mod tests { tx.send_control(Message::ClientCaps(caps())).unwrap(); tx.send_control(Message::ClientHdr(ClientHdr::none())) .unwrap(); + tx.send_control(Message::ClientPrefs(ClientPrefs::auto())) + .unwrap(); for _ in 0..3 { recv(&mut rx).await; } @@ -1544,6 +1705,8 @@ mod tests { tx.send_control(Message::ClientCaps(caps())).unwrap(); tx.send_control(Message::ClientHdr(ClientHdr::none())) .unwrap(); + tx.send_control(Message::ClientPrefs(ClientPrefs::auto())) + .unwrap(); for _ in 0..3 { recv(&mut rx).await; } diff --git a/crates/ezd-host-core/src/source.rs b/crates/ezd-host-core/src/source.rs index 78a4f9b..950ad6e 100644 --- a/crates/ezd-host-core/src/source.rs +++ b/crates/ezd-host-core/src/source.rs @@ -3,7 +3,7 @@ use ezd_link::{MuxClosed, MuxSender}; use ezd_proto::{ Codec, CursorShape, CursorState, DynamicRange, EncoderAttempt, EncoderCaps, Mastering, - RateControl, StreamColor, StreamConfig, + QualityLevel, RateControl, StreamColor, StreamConfig, }; use std::collections::HashSet; use std::sync::{Arc, Mutex}; @@ -180,6 +180,10 @@ pub trait VideoSource: Send { /// ignored (SDR BT.709 stream). fn set_dynamic_range(&mut self, _range: DynamicRange, _color: &StreamColor) {} + /// Tells the source the quality level the host applies, right before + /// [`start`](Self::start). Default: ignored (the normal, rate-controlled stream). + fn set_quality(&mut self, _quality: QualityLevel) {} + /// Starts capture and encoding with the negotiated configuration. The /// first produced frame must be a keyframe carrying `codec_config`. /// `cursor` stays valid until `stop`. diff --git a/crates/ezd-host-linux/src/ahead.rs b/crates/ezd-host-linux/src/ahead.rs index 8c51cb1..da1c0eb 100644 --- a/crates/ezd-host-linux/src/ahead.rs +++ b/crates/ezd-host-linux/src/ahead.rs @@ -281,6 +281,7 @@ mod tests { name: "libx264", kind: Kind::X264, node: None, + ten_bit: false, } } diff --git a/crates/ezd-host-linux/src/diag.rs b/crates/ezd-host-linux/src/diag.rs index acbf974..bdf5e15 100644 --- a/crates/ezd-host-linux/src/diag.rs +++ b/crates/ezd-host-linux/src/diag.rs @@ -26,9 +26,9 @@ pub fn section() -> Value { .map(|n| { let mut encs = Vec::new(); let plans = [if n.is_nvidia() { - Plan { name: "h264_nvenc", kind: Kind::Nvenc, node: Some(n.path.clone()) } + Plan { name: "h264_nvenc", kind: Kind::Nvenc, node: Some(n.path.clone()), ten_bit: false } } else { - Plan { name: "h264_vaapi", kind: Kind::Vaapi, node: Some(n.path.clone()) } + Plan { name: "h264_vaapi", kind: Kind::Vaapi, node: Some(n.path.clone()), ten_bit: false } }]; // Only non-NVIDIA nodes are probed in diag so the dGPU is not woken up needlessly. for p in plans { @@ -56,6 +56,7 @@ pub fn section() -> Value { name: "libx264", kind: Kind::X264, node: None, + ten_bit: false, }; let sw = plan_works(&x264); let token = |k| portal::token_path(k).is_some_and(|p| p.exists()); diff --git a/crates/ezd-host-linux/src/encode.rs b/crates/ezd-host-linux/src/encode.rs index 4866002..377ac7a 100644 --- a/crates/ezd-host-linux/src/encode.rs +++ b/crates/ezd-host-linux/src/encode.rs @@ -11,7 +11,7 @@ use ezd_ffmpeg::HwDevice; use ezd_ffmpeg::HwKind; use ezd_ffmpeg::sys as ff; use ezd_host_core::{EncoderSetup, now_us}; -use ezd_proto::EncoderAttempt; +use ezd_proto::{Codec, EncoderAttempt, QualityLevel}; use std::ffi::{CStr, CString, c_void}; use std::os::fd::IntoRawFd; use std::ptr; @@ -37,6 +37,8 @@ pub struct Plan { pub kind: Kind, /// Render node used (VAAPI, NVENC for description). pub node: Option, + /// 10-bit HDR10 variant (HEVC Main10, P010 input) instead of the 8-bit SDR one. + pub ten_bit: bool, } impl Plan { @@ -45,15 +47,19 @@ impl Plan { self.kind != Kind::X264 } - /// The HDR10 variant: HEVC Main10 (P010) instead of 8-bit H.264. + /// The HDR10 variant: HEVC Main10 (P010) instead of the 8-bit SDR one. pub fn hdr10(&self) -> bool { - ten_bit_encoder(self.name) + self.ten_bit } -} -/// Encoders that only run the 10-bit HDR10 chain (there is no 8-bit HEVC plan). -fn ten_bit_encoder(name: &str) -> bool { - name.starts_with("hevc_") + /// The codec this plan produces. + pub fn codec(&self) -> Codec { + if self.name.starts_with("hevc_") { + Codec::Hevc + } else { + Codec::H264 + } + } } /// Chooses the HDR10 encoders for `node`: HEVC Main10, best first. Empty without a hardware @@ -68,43 +74,47 @@ pub fn plan_hdr_encoders(node: Option<&RenderNode>, works: &dyn Fn(&Plan) -> boo name: "hevc_nvenc", kind: Kind::Nvenc, node: Some(n.path.clone()), + ten_bit: true, } } else { Plan { name: "hevc_vaapi", kind: Kind::Vaapi, node: Some(n.path.clone()), + ten_bit: true, } }; if works(&plan) { vec![plan] } else { Vec::new() } } -/// Chooses the encoders to offer for `node`, best first. `works` tells whether a plan was -/// verified on this machine. libx264 is always the last resort. +/// Chooses the SDR encoders to offer for `node`, best first: the H.264 primary, then 8-bit HEVC +/// on the same node (the tablet can ask for it, and lossless needs it). `works` tells whether a +/// plan was verified on this machine. libx264 is always the last resort. pub fn plan_encoders(node: Option<&RenderNode>, works: &dyn Fn(&Plan) -> bool) -> Vec { let mut out = Vec::new(); if let Some(n) = node { - let primary = if n.is_nvidia() { - Plan { - name: "h264_nvenc", - kind: Kind::Nvenc, - node: Some(n.path.clone()), - } + let (primary, hevc) = if n.is_nvidia() { + (("h264_nvenc", Kind::Nvenc), ("hevc_nvenc", Kind::Nvenc)) } else { - Plan { - name: "h264_vaapi", - kind: Kind::Vaapi, + (("h264_vaapi", Kind::Vaapi), ("hevc_vaapi", Kind::Vaapi)) + }; + for (name, kind) in [primary, hevc] { + let plan = Plan { + name, + kind, node: Some(n.path.clone()), + ten_bit: false, + }; + if works(&plan) { + out.push(plan); } - }; - if works(&primary) { - out.push(primary); } } let x264 = Plan { name: "libx264", kind: Kind::X264, node: None, + ten_bit: false, }; if works(&x264) { out.push(x264); @@ -383,6 +393,8 @@ struct EncParams<'a> { color: ColorInfo, /// 10-bit input (HEVC Main10 option sets). ten_bit: bool, + /// Quality level selecting the option sets. + quality: QualityLevel, } /// The FFmpeg enums of the H.273 codes of `c`; codes we do not stream stay unspecified. @@ -433,7 +445,7 @@ impl Enc { } let mut last = String::from("no option set"); let mut attempts = Vec::new(); - for set in ezd_ffmpeg::option_sets_for(p.name, p.ten_bit) { + for set in ezd_ffmpeg::option_sets_for(p.name, p.ten_bit, p.quality) { let mut attempt = EncoderAttempt { encoder: p.name.to_string(), options: ezd_ffmpeg::options_label(&set), @@ -1207,6 +1219,9 @@ pub struct Encoder { tags: StreamTags, fps_mhz: u32, kbps: u32, + /// 10-bit HDR10 stream (HEVC Main10). + ten_bit: bool, + quality: QualityLevel, active: Option, /// Setup of the encoder opened last, until the source takes it. setup: Option, @@ -1220,11 +1235,25 @@ impl Encoder { tags: StreamTags::default(), fps_mhz, kbps, + ten_bit: false, + quality: QualityLevel::Normal, active: None, setup: None, } } + /// Makes this the 10-bit HDR10 encoder (HEVC Main10) of `plan`. + pub fn with_ten_bit(mut self, ten_bit: bool) -> Encoder { + self.ten_bit = ten_bit; + self + } + + /// Sets the quality level that selects the option sets (default normal). + pub fn with_quality(mut self, quality: QualityLevel) -> Encoder { + self.quality = quality; + self + } + /// The setup of the encoder opened since the last call (every open makes a new one). pub fn take_setup(&mut self) -> Option { self.setup.take() @@ -1256,7 +1285,8 @@ impl Encoder { pix_fmt: p.pix_fmt, hw_frames: (!hw.is_null()).then_some(hw), color: self.tags.color, - ten_bit: ten_bit_encoder(self.name), + ten_bit: self.ten_bit, + quality: self.quality, })?; self.setup = enc.setup.take(); self.active = Some(Active { @@ -1280,7 +1310,7 @@ impl Encoder { // SAFETY: `frame` is a valid frame we own. unsafe { let f = &mut *frame.0; - if ten_bit_encoder(self.name) { + if self.ten_bit { // HDR10 frames carry the stream's colour tags (8-bit streams keep what the // conversion left, which the encoder ignores in favour of the context). (f.color_primaries, f.color_trc, f.colorspace, f.color_range) = @@ -1340,11 +1370,19 @@ impl Pipeline { /// New pipeline; nothing is opened until the first frame arrives. pub fn new(plan: Plan, fps_mhz: u32, kbps: u32) -> Pipeline { Pipeline { - enc: Encoder::new(plan.name, fps_mhz, kbps), + enc: Encoder::new(plan.name, fps_mhz, kbps).with_ten_bit(plan.ten_bit), conv: Converter::new(plan, fps_mhz), } } + /// Sets the quality level that selects the encoder option sets (default normal). + pub fn with_quality(self, quality: QualityLevel) -> Pipeline { + Pipeline { + enc: self.enc.with_quality(quality), + conv: self.conv, + } + } + /// The setup of the encoder opened since the last call. pub fn take_setup(&mut self) -> Option { self.enc.take_setup() @@ -1871,12 +1909,29 @@ mod tests { let intel = plan_encoders(Some(&node("/dev/dri/renderD129", 0x8086)), &all); assert_eq!( intel.iter().map(|p| p.name).collect::>(), - ["h264_vaapi", "libx264"] + ["h264_vaapi", "hevc_vaapi", "libx264"] ); assert_eq!(intel[0].node.as_deref(), Some("/dev/dri/renderD129")); - assert!(intel[0].hardware() && !intel[1].hardware()); + assert!(intel[0].hardware() && intel[1].hardware() && !intel[2].hardware()); + // The 8-bit HEVC plan is SDR: 8-bit input, no HDR10 chain. + assert!(!intel[1].hdr10() && !intel[1].ten_bit); + assert_eq!( + intel.iter().map(Plan::codec).collect::>(), + [Codec::H264, Codec::Hevc, Codec::H264] + ); let nv = plan_encoders(Some(&node("/dev/dri/renderD128", 0x10de)), &all); - assert_eq!(nv[0].name, "h264_nvenc"); + assert_eq!( + nv.iter().map(|p| p.name).collect::>(), + ["h264_nvenc", "hevc_nvenc", "libx264"] + ); + // The HEVC plan is offered only when it was verified on this machine. + let no_hevc = plan_encoders(Some(&node("/dev/dri/renderD128", 0x10de)), &|p| { + !p.name.starts_with("hevc_") + }); + assert_eq!( + no_hevc.iter().map(|p| p.name).collect::>(), + ["h264_nvenc", "libx264"] + ); // A broken hardware path leaves only x264; nothing works -> empty. let sw = plan_encoders(Some(&node("/dev/dri/renderD129", 0x8086)), &|p| { p.kind == Kind::X264 @@ -1928,6 +1983,7 @@ mod tests { name: "libx264", kind: Kind::X264, node: None, + ten_bit: false, }; let mut p = Pipeline::new(plan, 60_000, 8_000); let out = p @@ -1957,6 +2013,7 @@ mod tests { name: "libx264", kind: Kind::X264, node: None, + ten_bit: false, }; let mut p = Pipeline::new(plan, 60_000, 8_000); p.encode(test_shm_frame(320, 240, Q), false) @@ -1987,6 +2044,7 @@ mod tests { name: "h264_vaapi", kind: Kind::Vaapi, node: Some(node), + ten_bit: false, }; let mut p = Pipeline::new(plan, 60_000, 8_000); match p.encode(test_shm_frame(640, 360, Q), false) { @@ -2005,6 +2063,7 @@ mod tests { name: "hevc_vaapi", kind: Kind::Vaapi, node: Some(node), + ten_bit: true, } } @@ -2293,6 +2352,7 @@ mod tests { name: "h264_vaapi", kind: Kind::Vaapi, node: Some(node), + ten_bit: false, }; let mut p = Pipeline::new(plan, 60_000, 8_000); let out = p @@ -2321,6 +2381,7 @@ mod tests { name: "h264_vaapi", kind: Kind::Vaapi, node: Some(node), + ten_bit: false, }; let mut conv = Converter::new(plan, 60_000); let p = conv @@ -2513,11 +2574,87 @@ mod tests { ); } + fn hevc_nvenc_sdr_plan(node: String) -> Plan { + Plan { + name: "hevc_nvenc", + kind: Kind::Nvenc, + node: Some(node), + ten_bit: false, + } + } + + fn decode_hevc_first(out: &Output) -> ezd_ffmpeg::DecodedFrame { + let mut d = Decoder::new(Codec::Hevc).unwrap(); + if let Some(c) = &out.codec_config { + d.push_config(c).unwrap(); + } + let mut frames = d.decode(&out.data, 0).unwrap(); + frames.extend(d.flush().unwrap()); + frames.into_iter().next().expect("decoded a picture") + } + + /// 8-bit HEVC on NVENC from system memory at the tablet's size: the high set (constant + /// quantiser) and the lossless tune open, report what ran, and decode with the right colours. + #[test] + fn nvenc_hevc_8bit_high_and_lossless_if_available() { + let Some(node) = crate::adapter::list_render_nodes() + .into_iter() + .find(|n| n.is_nvidia()) + else { + return eprintln!("skip: no NVIDIA render node"); + }; + for (quality, mode, first_ok) in [ + (QualityLevel::High, ezd_proto::RateMode::Cqp, true), + (QualityLevel::Lossless, ezd_proto::RateMode::Lossless, true), + ] { + let mut p = Pipeline::new(hevc_nvenc_sdr_plan(node.path.clone()), 60_000, 8_000) + .with_quality(quality); + let out = match p.encode(test_shm_frame(2960, 1848, Q), false) { + Ok(Some(o)) => o, + Ok(None) => panic!("{quality:?}: no packet"), + Err(e) => return eprintln!("skip {quality:?}: {e}"), + }; + let setup = p.take_setup().expect("setup after the open"); + assert_eq!(setup.attempts[0].ok, first_ok, "{quality:?}: {setup:?}"); + assert_eq!(setup.rate.rate_mode(), mode, "{quality:?}: {setup:?}"); + if quality == QualityLevel::High { + assert_eq!(setup.rate.quality, ezd_ffmpeg::HIGH_QP as u16); + } else { + assert!(setup.attempts[0].options.contains("tune=lossless")); + } + assert!(out.keyframe && out.codec_config.is_some()); + check_colors(&decode_hevc_first(&out)); + } + } + + /// libx264 at the high level runs CRF 10 and reports it. + #[test] + fn x264_high_quality_reports_crf() { + let plan = Plan { + name: "libx264", + kind: Kind::X264, + node: None, + ten_bit: false, + }; + let mut p = Pipeline::new(plan, 60_000, 8_000).with_quality(QualityLevel::High); + let out = p + .encode(test_shm_frame(320, 240, Q), false) + .unwrap() + .unwrap(); + let setup = p.take_setup().expect("setup after the open"); + assert_eq!( + (setup.rate.rate_mode(), setup.rate.quality), + (ezd_proto::RateMode::Crf, 10) + ); + check_colors(&decode_first(&out)); + } + fn nvenc_plan(node: String) -> Plan { Plan { name: "h264_nvenc", kind: Kind::Nvenc, node: Some(node), + ten_bit: false, } } @@ -2678,6 +2815,7 @@ mod tests { name: "h264_vaapi", kind: Kind::Vaapi, node: Some(node.clone()), + ten_bit: false, }; bench("vaapi-shm", plan.clone(), &|| test_shm_frame(1920, 1080, Q)); bench("vaapi-dmabuf", plan, &|| { @@ -2688,6 +2826,7 @@ mod tests { name: "libx264", kind: Kind::X264, node: None, + ten_bit: false, }; bench("x264", x, &|| test_shm_frame(1920, 1080, Q)); } diff --git a/crates/ezd-host-linux/src/source.rs b/crates/ezd-host-linux/src/source.rs index 7fe54e8..9114657 100644 --- a/crates/ezd-host-linux/src/source.rs +++ b/crates/ezd-host-linux/src/source.rs @@ -17,7 +17,9 @@ use ezd_host_core::{ CursorSink, EncodedFrame, EncoderSetup, OutputInfo, PlatformOptions, SourceError, SourceHdr, VideoSource, }; -use ezd_proto::{Codec, DynamicRange, EncoderAttempt, EncoderCaps, StreamColor, StreamConfig}; +use ezd_proto::{ + Codec, DynamicRange, EncoderAttempt, EncoderCaps, QualityLevel, StreamColor, StreamConfig, +}; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -31,7 +33,11 @@ fn cache() -> &'static ProbeCache { /// Probes `plan` once per process (the result is cached, including failures). pub fn plan_works(plan: &Plan) -> Result<(), String> { - let key = (plan.name.to_string(), plan.node.clone().unwrap_or_default()); + // The 8-bit and the Main10 plan of an HEVC encoder are probed separately. + let key = ( + format!("{}{}", plan.name, if plan.ten_bit { "/10" } else { "" }), + plan.node.clone().unwrap_or_default(), + ); if let Some(r) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return r.clone(); } @@ -87,6 +93,8 @@ pub struct LinuxSource { /// Dynamic range negotiated for the session and the matching stream colour. range: DynamicRange, color: StreamColor, + /// Quality level the host applies (see `set_quality`). + quality: QualityLevel, running: Option, force_key: bool, /// The output the stream shows (virtual output or the captured monitor), after `start`. @@ -242,6 +250,7 @@ impl LinuxSource { hdr_source, range: DynamicRange::Sdr, color: StreamColor::sdr_bt709(), + quality: QualityLevel::Normal, running: None, force_key: false, output: None, @@ -267,18 +276,18 @@ impl LinuxSource { impl VideoSource for LinuxSource { fn encoders(&self) -> Vec { - let codec = |p: &Plan| { - if p.hdr10() { Codec::Hevc } else { Codec::H264 } - }; - self.plans - .iter() - .chain(&self.hdr_plans) - .map(|p| EncoderCaps { - codec: codec(p), - name: p.name.to_string(), - hardware: p.hardware(), - }) - .collect() + // The SDR and the Main10 plan of an HEVC encoder share a name: offer it once. + let mut caps: Vec = Vec::new(); + for p in self.plans.iter().chain(&self.hdr_plans) { + if !caps.iter().any(|c| c.name == p.name) { + caps.push(EncoderCaps { + codec: p.codec(), + name: p.name.to_string(), + hardware: p.hardware(), + }); + } + } + caps } fn source_hdr(&self) -> Option { @@ -298,6 +307,10 @@ impl VideoSource for LinuxSource { self.color = *color; } + fn set_quality(&mut self, quality: QualityLevel) { + self.quality = quality; + } + fn gpu_description(&self) -> String { self.node .as_ref() @@ -306,20 +319,13 @@ impl VideoSource for LinuxSource { } fn start(&mut self, cfg: &StreamConfig, cursor: CursorSink) -> Result<(), SourceError> { - // H.264 is the SDR codec; HEVC only exists here as HDR10 (Main10). + // H.264 and HEVC stream as SDR 8-bit; HEVC is also the HDR10 codec (Main10). let hdr10 = matches!(self.range, DynamicRange::Hdr10 { codec } if codec == cfg.codec); - match (cfg.codec, hdr10) { - (Codec::H264, false) | (Codec::Hevc, true) => {} - (Codec::Hevc, false) => { - return Err(SourceError::msg( - "the Linux host only produces HEVC for HDR10 streams", - )); - } - (codec, _) => { - return Err(SourceError::msg(format!( - "the Linux host cannot encode {codec:?}" - ))); - } + if !matches!(cfg.codec, Codec::H264 | Codec::Hevc) { + return Err(SourceError::msg(format!( + "the Linux host cannot encode {:?}", + cfg.codec + ))); } let plan = self .plans @@ -393,7 +399,10 @@ impl VideoSource for LinuxSource { tracing::info!(convert_ahead = ?ahead, "encode pipeline ready"); let input = match ahead { Some(start) => Input::Ahead { - enc: Encoder::new(plan.name, cfg.refresh_mhz, cfg.bitrate_kbps).with_tags(tags), + enc: Encoder::new(plan.name, cfg.refresh_mhz, cfg.bitrate_kbps) + .with_ten_bit(plan.ten_bit) + .with_quality(self.quality) + .with_tags(tags), conv: ConvertAhead::start( Converter::new(plan, cfg.refresh_mhz).with_tags(tags), capture.frames(), @@ -402,7 +411,9 @@ impl VideoSource for LinuxSource { .map_err(SourceError::msg)?, }, None => Input::Serial( - Pipeline::new(plan, cfg.refresh_mhz, cfg.bitrate_kbps).with_tags(tags), + Pipeline::new(plan, cfg.refresh_mhz, cfg.bitrate_kbps) + .with_quality(self.quality) + .with_tags(tags), ), }; self.running = Some(Running { diff --git a/crates/ezd-host-service/src/config.rs b/crates/ezd-host-service/src/config.rs index 5f1731f..fd33b94 100644 --- a/crates/ezd-host-service/src/config.rs +++ b/crates/ezd-host-service/src/config.rs @@ -4,7 +4,7 @@ use ezd_host_core::{ InputBackend, PenMode, PlatformOptions, SessionEvent, SessionOptions, UnavailableBackend, }; use ezd_link::VideoCredit; -use ezd_proto::{HdrMode, LinkKind, Prefs}; +use ezd_proto::{Codec, HdrMode, LinkKind, Prefs, QualityLevel}; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use std::sync::Arc; @@ -94,6 +94,55 @@ impl From for HdrMode { } } +/// Video codec the host asks for (serializable mirror of `Option`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CodecChoice { + /// Use the tablet's preference, else the best codec both sides support. + #[default] + Auto, + /// H.264. + H264, + /// HEVC. + Hevc, + /// AV1. + Av1, +} + +impl From for Option { + fn from(c: CodecChoice) -> Self { + match c { + CodecChoice::Auto => None, + CodecChoice::H264 => Some(Codec::H264), + CodecChoice::Hevc => Some(Codec::Hevc), + CodecChoice::Av1 => Some(Codec::Av1), + } + } +} + +/// Stream quality the host asks for (serializable mirror of [`QualityLevel`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum QualityChoice { + /// Use the tablet's choice (rate-controlled stream if it makes none). + #[default] + Normal, + /// Near-transparent fixed quantiser (USB only), whatever the tablet asks. + High, + /// Lossless HEVC (USB only), whatever the tablet asks. + Lossless, +} + +impl From for QualityLevel { + fn from(c: QualityChoice) -> Self { + match c { + QualityChoice::Normal => QualityLevel::Normal, + QualityChoice::High => QualityLevel::High, + QualityChoice::Lossless => QualityLevel::Lossless, + } + } +} + /// Congestion controller of the Wi-Fi link. #[cfg(feature = "wifi")] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -187,6 +236,11 @@ pub struct ServiceConfig { pub open_app_on_connect: bool, /// HDR10 streaming policy (`hdr = "auto" | "off"`). pub hdr: HdrChoice, + /// Codec to use, overriding the tablet's preference; `auto` follows the tablet. + pub codec: CodecChoice, + /// Quality to use, overriding the tablet's choice unless `normal`. Above `normal` it applies + /// to USB sessions only. + pub quality: QualityChoice, /// Wi-Fi listener; `None` leaves Wi-Fi off. Sessions on it always use the network link kind, /// whatever `link` says. #[cfg(feature = "wifi")] @@ -217,6 +271,8 @@ impl Default for ServiceConfig { pen_mode: PenChoice::default(), open_app_on_connect: true, hdr: HdrChoice::default(), + codec: CodecChoice::default(), + quality: QualityChoice::default(), #[cfg(feature = "wifi")] wifi: None, detach_grace_ms: ezd_host_core::DEFAULT_DETACH_GRACE.as_millis() as u32, @@ -237,6 +293,8 @@ impl ServiceConfig { encoder: self.encoder.clone(), scale_permille: self.scale_permille, hdr: self.hdr.into(), + codec: self.codec.into(), + quality: self.quality.into(), ..Prefs::default() } } @@ -313,6 +371,8 @@ mod tests { encoder: Some("h264_nvenc".into()), scale_permille: 750, hdr: HdrChoice::Off, + codec: CodecChoice::Hevc, + quality: QualityChoice::High, ..ServiceConfig::default() }; assert_eq!( @@ -324,6 +384,8 @@ mod tests { encoder: Some("h264_nvenc".into()), scale_permille: 750, hdr: HdrMode::Off, + codec: Some(Codec::Hevc), + quality: QualityLevel::High, ..Prefs::default() } ); diff --git a/crates/ezd-host-service/src/lib.rs b/crates/ezd-host-service/src/lib.rs index 65792e8..2774b1f 100644 --- a/crates/ezd-host-service/src/lib.rs +++ b/crates/ezd-host-service/src/lib.rs @@ -30,7 +30,10 @@ pub mod store; mod wifi; pub use adb::{AdbClient, AdbLocator, is_network_serial}; -pub use config::{AdbMode, AoaMode, HdrChoice, LinkChoice, PenChoice, ServiceConfig, SourceKind}; +pub use config::{ + AdbMode, AoaMode, CodecChoice, HdrChoice, LinkChoice, PenChoice, QualityChoice, ServiceConfig, + SourceKind, +}; #[cfg(feature = "wifi")] pub use config::{CongestionChoice, WifiConfig}; pub use error::ServiceError; diff --git a/crates/ezd-host-service/src/service.rs b/crates/ezd-host-service/src/service.rs index d8110b5..c6cf486 100644 --- a/crates/ezd-host-service/src/service.rs +++ b/crates/ezd-host-service/src/service.rs @@ -643,8 +643,8 @@ pub(crate) mod tests { use ezd_link::adb::DeviceState as AdbState; use ezd_link::{MuxReceiver, MuxSender}; use ezd_proto::{ - Bye, ClientCaps, ClientHdr, Codec, DEFAULT_CHUNK_LEN, DecoderCaps, DisplayInfo, Hello, - Message, Role, + Bye, ClientCaps, ClientHdr, ClientPrefs, Codec, DEFAULT_CHUNK_LEN, DecoderCaps, + DisplayInfo, Hello, Message, Role, }; use tokio::time::timeout; @@ -723,6 +723,8 @@ pub(crate) mod tests { tx.send_control(Message::ClientCaps(caps())).unwrap(); tx.send_control(Message::ClientHdr(ClientHdr::none())) .unwrap(); + tx.send_control(Message::ClientPrefs(ClientPrefs::auto())) + .unwrap(); let mut seen = 0; while seen < 4 { match timeout(WAIT, rx.recv()).await.unwrap().unwrap().unwrap() { diff --git a/crates/ezd-host-windows/src/win/ffenc.rs b/crates/ezd-host-windows/src/win/ffenc.rs index bebb9b5..e37c26a 100644 --- a/crates/ezd-host-windows/src/win/ffenc.rs +++ b/crates/ezd-host-windows/src/win/ffenc.rs @@ -351,7 +351,10 @@ impl ZeroCopyEncoder { return Err(format!("encoder {name} not available in this FFmpeg build")); } let mut last = String::from("no option set"); - for set in ezd_ffmpeg::option_sets_for(name, format.ten_bit) { + // The Windows host does not plumb the quality level yet: always the normal sets. + for set in + ezd_ffmpeg::option_sets_for(name, format.ten_bit, ezd_proto::QualityLevel::Normal) + { match enc.try_open(codec, &set, width, height, refresh_mhz, bitrate_kbps) { Ok(()) => { enc.pkt = ffi::av_packet_alloc(); diff --git a/crates/ezd-host/i18n/de/ezd-host.ftl b/crates/ezd-host/i18n/de/ezd-host.ftl index 4cd61bb..ce346cf 100644 --- a/crates/ezd-host/i18n/de/ezd-host.ftl +++ b/crates/ezd-host/i18n/de/ezd-host.ftl @@ -15,6 +15,8 @@ run-link = Verbindungsart: wählt die Standard-Bitratenobergrenze (usb 80 Mbit/s run-max-refresh-mhz = Höchste zu verwendende Bildwiederholrate in Millihertz. run-bitrate-kbps = Feste Bitrate in kbit/s (auf die Obergrenze der Verbindung begrenzt). run-encoder = Erzwingt einen bestimmten Encoder per Namen. +run-codec = Videocodec: auto, h264, hevc oder av1. Auto folgt der Wahl des Tablets, sonst dem besten Codec, den beide Seiten unterstützen. +run-quality = Streamqualität: normal, high oder lossless. High und lossless gelten nur bei einer USB-Verbindung (lossless benötigt HEVC, sonst high). Normal folgt der Wahl des Tablets. run-adapter = Zu verwendende GPU/Adapter (`--source virtual`). run-prefer-dgpu = Bevorzugt die dedizierte GPU (`--source virtual`). run-output = Nimmt diese vorhandene Ausgabe auf, statt eine neue zu erstellen (`--source virtual`). diff --git a/crates/ezd-host/i18n/en/ezd-host.ftl b/crates/ezd-host/i18n/en/ezd-host.ftl index 24385f4..d6017a4 100644 --- a/crates/ezd-host/i18n/en/ezd-host.ftl +++ b/crates/ezd-host/i18n/en/ezd-host.ftl @@ -15,6 +15,8 @@ run-link = Link kind: selects the default bitrate ceiling (usb 80 Mbps, network run-max-refresh-mhz = Highest refresh rate to use, in millihertz. run-bitrate-kbps = Fixed bitrate in kbit/s (clamped to the link ceiling). run-encoder = Force a specific encoder by name. +run-codec = Video codec: auto, h264, hevc or av1. Auto follows the tablet's choice, else the best codec both sides support. +run-quality = Stream quality: normal, high or lossless. High and lossless apply on a USB link only (lossless needs HEVC, else high). Normal follows the tablet's choice. run-adapter = GPU/adapter to use (`--source virtual`). run-prefer-dgpu = Prefer the discrete GPU (`--source virtual`). run-output = Capture this existing output instead of creating one (`--source virtual`). diff --git a/crates/ezd-host/i18n/es/ezd-host.ftl b/crates/ezd-host/i18n/es/ezd-host.ftl index 0423112..8bdab26 100644 --- a/crates/ezd-host/i18n/es/ezd-host.ftl +++ b/crates/ezd-host/i18n/es/ezd-host.ftl @@ -15,6 +15,8 @@ run-link = Tipo de enlace: elige el límite de bitrate por defecto (usb 80 Mbps, run-max-refresh-mhz = Frecuencia de actualización máxima a usar, en milihercios. run-bitrate-kbps = Bitrate fijo en kbit/s (limitado al máximo del enlace). run-encoder = Fuerza un codificador concreto por nombre. +run-codec = Códec de video: auto, h264, hevc o av1. Auto sigue la elección de la tablet y, si no, el mejor códec que soporten ambos. +run-quality = Calidad del stream: normal, high o lossless. High y lossless se aplican solo en un enlace USB (lossless requiere HEVC; si no, high). Normal sigue la elección de la tablet. run-adapter = GPU/adaptador a usar (`--source virtual`). run-prefer-dgpu = Prefiere la GPU dedicada (`--source virtual`). run-output = Captura esta salida existente en lugar de crear una (`--source virtual`). diff --git a/crates/ezd-host/i18n/fr/ezd-host.ftl b/crates/ezd-host/i18n/fr/ezd-host.ftl index 4c40483..335d3b6 100644 --- a/crates/ezd-host/i18n/fr/ezd-host.ftl +++ b/crates/ezd-host/i18n/fr/ezd-host.ftl @@ -15,6 +15,8 @@ run-link = Type de liaison : choisit le débit maximal par défaut (usb 80 Mbit/ run-max-refresh-mhz = Fréquence de rafraîchissement maximale à utiliser, en millihertz. run-bitrate-kbps = Débit fixe en kbit/s (limité au maximum de la liaison). run-encoder = Force un encodeur précis par son nom. +run-codec = Codec vidéo : auto, h264, hevc ou av1. Auto suit le choix de la tablette, sinon le meilleur codec pris en charge des deux côtés. +run-quality = Qualité du flux : normal, high ou lossless. High et lossless ne s'appliquent que sur une liaison USB (lossless nécessite HEVC, sinon high). Normal suit le choix de la tablette. run-adapter = GPU/adaptateur à utiliser (`--source virtual`). run-prefer-dgpu = Préfère le GPU dédié (`--source virtual`). run-output = Capture cette sortie existante au lieu d'en créer une (`--source virtual`). diff --git a/crates/ezd-host/i18n/it/ezd-host.ftl b/crates/ezd-host/i18n/it/ezd-host.ftl index 56ba808..1c766f6 100644 --- a/crates/ezd-host/i18n/it/ezd-host.ftl +++ b/crates/ezd-host/i18n/it/ezd-host.ftl @@ -15,6 +15,8 @@ run-link = Tipo di collegamento: sceglie il limite di bitrate predefinito (usb 8 run-max-refresh-mhz = Frequenza di aggiornamento massima da usare, in millihertz. run-bitrate-kbps = Bitrate fisso in kbit/s (limitato al massimo del collegamento). run-encoder = Forza un encoder specifico per nome. +run-codec = Codec video: auto, h264, hevc o av1. Auto segue la scelta del tablet, altrimenti il miglior codec supportato da entrambi. +run-quality = Qualità dello stream: normal, high o lossless. High e lossless valgono solo su un collegamento USB (lossless richiede HEVC, altrimenti high). Normal segue la scelta del tablet. run-adapter = GPU/adattatore da usare (`--source virtual`). run-prefer-dgpu = Preferisce la GPU dedicata (`--source virtual`). run-output = Cattura questa uscita esistente invece di crearne una (`--source virtual`). diff --git a/crates/ezd-host/i18n/pt/ezd-host.ftl b/crates/ezd-host/i18n/pt/ezd-host.ftl index 192480b..59f26d3 100644 --- a/crates/ezd-host/i18n/pt/ezd-host.ftl +++ b/crates/ezd-host/i18n/pt/ezd-host.ftl @@ -15,6 +15,8 @@ run-link = Tipo de conexão: define o limite de bitrate padrão (usb 80 Mbps, re run-max-refresh-mhz = Maior taxa de atualização a usar, em milihertz. run-bitrate-kbps = Bitrate fixo em kbit/s (limitado ao máximo da conexão). run-encoder = Força um codificador específico pelo nome. +run-codec = Codec de vídeo: auto, h264, hevc ou av1. Auto segue a escolha do tablet e, senão, o melhor codec suportado pelos dois lados. +run-quality = Qualidade do stream: normal, high ou lossless. High e lossless valem apenas em um link USB (lossless requer HEVC, senão high). Normal segue a escolha do tablet. run-adapter = GPU/adaptador a usar (`--source virtual`). run-prefer-dgpu = Prefere a GPU dedicada (`--source virtual`). run-output = Captura esta saída existente em vez de criar uma (`--source virtual`). diff --git a/crates/ezd-host/src/run.rs b/crates/ezd-host/src/run.rs index 59c23b9..66d7255 100644 --- a/crates/ezd-host/src/run.rs +++ b/crates/ezd-host/src/run.rs @@ -5,8 +5,8 @@ use anyhow::{Result, anyhow}; use clap::{Args, Command, ValueEnum}; use ezd_host_core::SessionSummary; use ezd_host_service::{ - AdbMode, AoaMode, HostEvent, HostService, HostStatus, LinkChoice, PenChoice, ServiceConfig, - ServiceError, SourceKind, + AdbMode, AoaMode, CodecChoice, HostEvent, HostService, HostStatus, LinkChoice, PenChoice, + QualityChoice, ServiceConfig, ServiceError, SourceKind, }; use i18n_embed::fluent::FluentLanguageLoader; use std::net::ToSocketAddrs; @@ -35,6 +35,21 @@ pub enum PenModeArg { Mouse, } +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum CodecArg { + Auto, + H264, + Hevc, + Av1, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum QualityArg { + Normal, + High, + Lossless, +} + // Option help texts live in i18n//ezd-host.ftl (`run-