Skip to content

High-quality and lossless video over USB - #12

Merged
FacuM merged 6 commits into
mainfrom
feat/usb-hq-video
Oct 27, 2026
Merged

FacuM merged 6 commits into
mainfrom
feat/usb-hq-video

Conversation

@FacuM

@FacuM FacuM commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Lets the tablet pick the video codec and quality level for USB streaming, and makes the host report which stream was actually negotiated. Also raises first-frame quality, fixes KDE mode switching on virtual outputs, speeds up AOA bulk writes, and makes still frames exact at the Lossless level.

Changes

  • The tablet can choose the codec (auto, H.264, HEVC, AV1) and a quality level (Normal, High, Lossless) for USB. The host reports the negotiated stream (applied quality and attempts), and the on-screen display shows it. Strings are localized in en, es, fr, pt, it and de.
  • First-frame quality is raised.
  • KDE virtual outputs are addressed by id when switching mode. This fixes the fallback to 60 Hz.
  • AOA bulk writes go out in 64 KiB - 1 chunks, so no write is an exact multiple of max_packet that would be split into a 1-byte transfer. A ~135 KB frame now transfers in ~7 ms instead of 11-16 ms.
  • At Lossless, motion is encoded with the High set (QP 10). The last frame is re-encoded losslessly as an IDR 150 ms after the image stops. Full-screen scroll goes from ~10 fps to ~40 fps. The still image becomes exact ~170 ms after it stops.

Scope notes

  • High and Lossless apply on a USB link only; on the network link the host keeps the normal rate control. The codec choice applies to both links.
  • The protocol documentation (docs/protocol.md) was updated for the new negotiation fields.

Verification

  • cargo test -p ezd-host-linux: 186 passed, 1 ignored hardware test (run separately; passed on a GTX 1650 with NVENC).
  • cargo clippy -p ezd-host-linux --all-targets: clean.
  • Measured on a real tablet over AOA: fps, transfer times, refine IDRs after stillness, and no decoder errors on parameter-set changes.

Closes: no issue number was provided.

FacuM added 6 commits October 27, 2026 00:30
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.
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.
…1a081

# Conflicts:
#	android/app/src/main/kotlin/com/librestatic/ezdisplay/MainActivity.kt
#	android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/SettingsScreen.kt
#	android/app/src/main/kotlin/com/librestatic/ezdisplay/settings/SettingsStore.kt
#	android/app/src/main/res/values-de/strings.xml
#	android/app/src/main/res/values-es/strings.xml
#	android/app/src/main/res/values-fr/strings.xml
#	android/app/src/main/res/values-it/strings.xml
#	android/app/src/main/res/values-pt/strings.xml
#	android/app/src/main/res/values/strings.xml
#	android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsScreenshotTest.kt
#	android/app/src/test/kotlin/com/librestatic/ezdisplay/settings/SettingsStoreTest.kt
#	crates/ezd-android/src/caps.rs
#	crates/ezd-android/src/session.rs
#	crates/ezd-client-core/src/session.rs
#	crates/ezd-fake-client/i18n/de/ezd-fake-client.ftl
#	crates/ezd-fake-client/i18n/en/ezd-fake-client.ftl
#	crates/ezd-fake-client/i18n/es/ezd-fake-client.ftl
#	crates/ezd-fake-client/i18n/fr/ezd-fake-client.ftl
#	crates/ezd-fake-client/i18n/it/ezd-fake-client.ftl
#	crates/ezd-fake-client/i18n/pt/ezd-fake-client.ftl
#	crates/ezd-fake-client/src/lib.rs
#	crates/ezd-fake-client/src/main.rs
#	crates/ezd-host-core/src/lib.rs
#	crates/ezd-host-core/src/session.rs
#	crates/ezd-host-core/src/source.rs
#	crates/ezd-host-service/src/config.rs
#	crates/ezd-host-service/src/lib.rs
#	crates/ezd-host-service/src/service.rs
#	crates/ezd-host/src/run.rs
#	crates/ezd-proto/src/lib.rs
#	crates/ezd-proto/src/msg.rs
#	crates/ezd-proto/src/negotiate.rs
#	docs/protocol.md
The portal names a virtual output after the requesting app id, which has
dots, and kscreen-doctor splits its arguments on dots. The custom 120 Hz
mode was never added and the tablet ran at 60 Hz. Use the numeric id and
check the refresh rate, not only the size, after switching.
The writer used 16 KiB chunks, a multiple of the endpoint's max packet,
so transfer_len split every chunk into 16383 + 1 bytes. About 44% of the
transfers carried one byte and each paid a full round trip to the
tablet, whose accessory gadget takes at most 16 KiB per read.

An odd write size is never split. Bulk reads stay at 16 KiB. With a
full-screen scroll (about 135 KB per frame) the link now moves a frame
in about 7.3 ms instead of 11-16 ms.
Lossless frames of moving content are around 1 MB at 2960x1848 and cap the stream at about
10 fps. At Lossless the host now encodes motion with the High set (QP 10) and, 150 ms after the
last new frame, re-encodes that frame with a second, lossless encoder as one IDR with its own
parameter sets. A full-screen scroll runs at about 40 fps with ~80 KB frames, and the still
image becomes exact a moment after it stops.
@FacuM
FacuM merged commit f1ba5a6 into main Oct 27, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant