Skip to content

fix: bound stream size by what the client can actually decode - #62

Merged
tranvuongquocdat merged 1 commit into
tranvuongquocdat:mainfrom
meta-boy:fix/throughput-aware-decode-ceiling
Sep 5, 2026
Merged

tranvuongquocdat merged 1 commit into
tranvuongquocdat:mainfrom
meta-boy:fix/throughput-aware-decode-ceiling

Conversation

@meta-boy

@meta-boy meta-boy commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Problem

The client advertises its decoder ceiling from MediaCodecInfo.VideoCapabilities.supportedWidths/Heights.upper. That is the size limit out of the vendor's media_codecs.xml, and vendors routinely overstate it. The constraint that actually binds is blocks-per-second, which that API never reports.

Measured on a Galaxy Tab S9 FE (Exynos, 2304x1440 90 Hz panel):

<Limit name="size"              min="64x64" max="8192x8192" />
<Limit name="blocks-per-second" min="1"     max="1224000"   />

So the client advertised 8192x8192 and the Mac's clamp in ScreenCapture.encodeSize never engaged. The real budget at 16x16 blocks is about 1.22M blocks/s:

Stream Blocks/frame @60fps Sustainable
1920x1200 9,000 540,000 yes
2304x1440 12,960 777,600 yes
2560x1600 16,000 960,000 yes
4608x2880 (2304x1440 HiDPI) 51,840 3,110,400 no, 2.5x over

MediaCodec.configure() accepts anything inside 8192x8192. Above the throughput budget the decoder starts, accepts input buffers, and never outputs a frame. That is the black screen in #41. HiDPI made it certain rather than likely, because it doubles both axes.

There is a second wall behind that one. MAX_FRAME_SIZE in StreamClient was 5MB, and exceeding it throws IOException and drops the connection. At the higher bitrate presets a keyframe above 1080p clears 5MB, so the same resolution bump could show up as a disconnect instead of a black screen.

Fix

Bound the stream by what the client can display and decode, rather than by a number the decoder made up.

  • CodecCapabilities.maxStreamSize() starts at the panel size and shrinks until areSizeAndRateSupported() passes. That is the API that consults blocks-per-second. The panel is the upper bound because the client downscales anything larger on arrival, so decode budget spent above it buys nothing.
  • PanelGeometry reads the true panel from Display.Mode.physicalWidth/Height, which does not change with rotation, and the peak rate across supportedModes.
  • MAX_FRAME_SIZE goes from 5MB to 32MB. It is only a desync guard. acquireBuffer allocates the real frame size, so a generous bound costs nothing in steady state.
  • CodecLimits.clampToClientLimit() transposes the ceiling when the capture's orientation differs from the client's. The limit stands for a macroblock area budget, which does not care which side is longer, so a portrait display measured against a landscape box was losing pixels for no reason.
  • The decoder-stall toast used the nominal limit. The one message a user sees when this fails read "max ~8192x8192". It now reports the sustainable size.
  • StreamClient requires a Context. The USB path constructed it without one and skipped the panel probe. The compiler now prevents that.

I renamed maxDecodeSize to nominalMaxDecodeSize so the trap is visible at the call site.

Why this is general

Nothing here keys off a model or vendor. An inflated size limit sitting next to a real blocks-per-second budget is how media_codecs.xml normally reads on Exynos, MediaTek and Qualcomm, so any device whose panel outruns its decoder at the requested frame rate hit this. Devices that report nothing usable keep their old behavior through the existing fallback.

Bounding at the panel also makes HiDPI usable on a tablet. macOS renders at 2x, SCStream downsamples to the panel size before encode, and the client decodes a frame it can sustain. That is sharper than a 1x capture of the same size, because it is supersampled.

Verification

Measured on-device before and after, from a side-by-side build:

panel = 2304x1440 @ 90Hz, mime = video/hevc

nominal (old, advertised) = (8192, 8192)
maxStreamSize @ 60fps     = (2304, 1440)
maxStreamSize @ 90fps     = (2304, 1440)
maxStreamSize @ 120fps    = (1968, 1232)   <- backs off, 120fps exceeds the budget

The 120 fps row is the one I would check first. 2304x1440 at 120 fps needs 1,555,200 blocks/s against a 1,224,000 budget, so it steps down instead of going black.

I also confirmed that HiDPI virtual-display creation was never the broken part. A small test program against CGVirtualDisplay reproduces 2304x1440 logical @ 4608x2880 pixels, backingScaleFactor 2.0 correctly. The failure was always downstream, at encode size.

macOS builds clean and passes 39/39 tests, including 4 new orientation cases in CodecLimitsTests. Android compiles clean, unit tests pass, ktlint is clean.

A decoder-sizing bug this fixes on the way past

The display-config message is not only what the client's overlay renders. It is also what the client sizes its decoder from:

// VideoDecoder.kt, decoder selection
val supported     = videoCaps.isSizeSupported(width, height)
val rateSupported = videoCaps.areSizeAndRateSupported(width, height, targetRate)

// VideoDecoder.kt, configuration
MediaFormat.createVideoFormat(mime, currentWidth, currentHeight)

With HiDPI on, AppDelegate deliberately sends the logical resolution so the overlay matches the Mac's dropdown, while the stream is the doubled one. Observed on 0.11.2 with HiDPI at 2560x1600:

Sent display config:  2560x1600      <- client sizes its decoder from this
Stream configured:    5120x3200      <- client is actually fed this
Client decoder limit: 8192x8192

So the client asks "can you decode 2560x1600 at 90fps", is told yes, picks a decoder on that basis, and is then handed 5120x3200. The only throughput check that exists on the client runs against a resolution that is not the stream. MediaCodec does adapt afterwards from the SPS, but selection and the capability check have already happened by then.

This PR corrects that wherever the clamp engages, through the existing branch at AppDelegate.swift:656: once enc != physical, the real encoded size goes out and the decoder is configured for the stream it will receive.

One narrow gap remains, and this PR does not close it: HiDPI on a device that genuinely can decode the doubled frame, where the clamp stays inactive and the halved number still goes out. Fixing that properly means always sending the encoded size and carrying the desktop geometry in a separate field for the overlay. That is a protocol change and belongs in its own PR.

Two things I left alone

Both are deliberate choices with UI behind them, so they seemed like yours to make.

The default bitrate of 1000 Mbps sits above the declared bitrate range 1-80000000 (80 Mbps) on this class of decoder. AOSP does not enforce that range in areSizeAndRateSupported, so this fix does not catch it. A lower default, or clamping against the decoder's declared bitrate, would.

resetToDefaults() sets 120 Hz, which is higher than many tablet panels can show. Frame rate trades against resolution in the same budget, as the table above shows, so a default above the panel's peak costs resolution without anyone seeing why.

Clients advertised their decoder's nominal `size` limit, which vendors
routinely overstate — the real ceiling is the `blocks-per-second` budget.
An Exynos HEVC decoder reports 8192x8192 while sustaining ~1.22M
blocks/s, so the Mac's clamp never engaged: any resolution above ~1080p
configured successfully and then decoded to a black screen.

- Advertise the largest size the panel can show that MediaCodec's
  areSizeAndRateSupported() accepts at the panel's peak refresh rate.
  Falls back to the nominal limit only when panel geometry is
  unavailable, so devices that report nothing behave as before.
- Raise MAX_FRAME_SIZE from 5MB to 32MB. A keyframe above 1080p at high
  bitrates exceeded it, and the overflow surfaced as an IOException that
  dropped the connection rather than a decode failure.
- Transpose the reported ceiling when the capture's orientation differs
  from the client's, so a portrait display is not clamped against a
  landscape box. The limit stands for a macroblock area budget, which is
  indifferent to which side is longer.
- Report the sustainable size in the decoder-stall toast. It used the
  nominal limit, so the one message shown when this failure happens told
  users "max ~8192x8192".
- Require a Context in StreamClient so the panel probe cannot be
  silently skipped; the USB path was constructing it without one.

Bounding the stream at the panel also makes HiDPI usable on a tablet:
macOS renders at 2x, SCStream downsamples to the panel size before
encode, and the client decodes a frame it can sustain.
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

@meta-boy is attempting to deploy a commit to the tranvuongquocdat2-7001's projects Team on Vercel.

A member of the Team first needs to authorize it.

tranvuongquocdat added a commit that referenced this pull request Sep 5, 2026
…c for faster streams

The limit from #62 was measured at the panel's peak refresh rate, which
shrank the picture on 120/144 Hz tablets even when the Mac streams at the
60 Hz default. The client now advertises the largest frame its decoder
sustains at a fixed 60 fps reference; when the Mac streams faster it scales
the box's area by 60/fps (blocks-per-second is linear in frame rate), so a
90/120 Hz session still lands inside the decoder's budget.
@tranvuongquocdat
tranvuongquocdat merged commit 8e9f354 into tranvuongquocdat:main Sep 5, 2026
1 check failed
SirRiddle pushed a commit to SirRiddle/SideScreen that referenced this pull request Sep 10, 2026
…c for faster streams

The limit from tranvuongquocdat#62 was measured at the panel's peak refresh rate, which
shrank the picture on 120/144 Hz tablets even when the Mac streams at the
60 Hz default. The client now advertises the largest frame its decoder
sustains at a fixed 60 fps reference; when the Mac streams faster it scales
the box's area by 60/fps (blocks-per-second is linear in frame rate), so a
90/120 Hz session still lands inside the decoder's budget.
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.

2 participants