Skip to content

Settings panel: Picture Smoothing (Automatic, None, Low, Medium, High) - #107

Open
addhumal wants to merge 13 commits into
Saffsanity:mainfrom
addhumal:picture-smoothing
Open

addhumal wants to merge 13 commits into
Saffsanity:mainfrom
addhumal:picture-smoothing

Conversation

@addhumal

@addhumal addhumal commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Closes #106.

What it does

Adds Settings › This iPad / This iPhone › Picture Smoothing with five levels (Automatic, None, Low, Medium, High), stored per device (Sill.pictureSmoothing) and never sent over the wire.

  • Automatic (default): uses AVSampleBufferDisplayLayer as today, untouched.
  • None / Low / Medium / High: MetalStreamRenderer (pbxproj AB21–AB23 / FB21–FB23) decodes HEVC frames with VTDecompressionSession to BGRA and draws them in the aspect-fit video rectangle under the pointer sprite using a two-pass separable Metal shader (StreamShader: horizontal pass into an rgba16Float intermediate texture, vertical pass clamped into the drawable). When downscaling (such as a Retina stream on an iPad), the filter support widens by the scale factor so fine detail averages out instead of aliasing.

Switching between Automatic and the Metal renderer re-picks the active source once for a fresh keyframe; switching between None, Low, Medium and High redraws the last decoded frame in place. If iOS invalidates the hardware decompression session in the background, MetalStreamRenderer recreates it and asks for a keyframe through HEVCDisplayView.onNeedsKeyframe (PictureSmoothing.DecoderRecovery). layer.framebufferOnly is false so UIView.drawHierarchy (#96, #117) can read the drawable.

Why

AVSampleBufferDisplayLayer ignores CALayer's magnificationFilter and minificationFilter (verified on the simulator: linear and nearest produced byte-identical pixels). Decoding with VideoToolbox and scaling in a two-pass Metal shader gives control over the resampling kernel at every scale factor:

Level Filter Support (source px) Effect
Automatic System video layer – Default hardware display layer
None Nearest 0.5 Crisp pixel edges, zero blur
Low Bilinear (tent) 1.0 Fast linear blend
Medium Catmull-Rom bicubic 2.0 Sharp text with controlled ringing
High Lanczos-3 3.0 Widest 6-tap windowed sinc

Checks

  • Tests/checks/picture-smoothing: 52 model/kernel/resampler/recovery checks + 65 GPU checks running StreamShader on Metal against the CPU reference across all four filters, both axes, upscaling, downscaling and 1:1. 22 of 22 mutants caught. Added to CI's mutants list and the checks README.
  • Tests/checks/run-all.sh and swift build -c release pass with only the known warnings; the iOS app builds for the simulator.
  • Verified live on an iPad Air simulator (iOS 27.0) against SillHost --synthetic with an 8-pixel stripe test pattern (Automatic 7.8 % intermediate greys, None 0.0 %, Low 7.8 %, Medium / High 7.0 %) and mid-stream switching via -SillSmoothingAt.

From the roadmap (item 5, smoothing). A spike showed the system's video layer ignores a layer's
scaling filter (linear and nearest gave the same pixels, byte for byte), so every level but
Automatic now draws with Sill's own renderer:

- Settings › This iPad › Picture Smoothing: Automatic, None, Low, Medium, High (Windows App's
  interpolation levels), this device's own, never sent (Sill.pictureSmoothing). Automatic is the
  system's video layer, as before, untouched.
- MetalStreamRenderer: the stream decoded with VideoToolbox to BGRA and each frame drawn with Metal
  as it decodes, in the picture's aspect-fit rectangle under the pointer sprite, in two passes
  (across into an rgba16Float texture, then down, clamped): None the nearest pixel, Low bilinear,
  Medium Catmull-Rom, High Lanczos-3, each widened by the shrink when the picture is smaller than
  the stream (the usual case: a Retina stream on an iPad), so fine detail averages out instead of
  aliasing. The layer takes the stream's colour space. No Metal or no shader: the system's layer
  draws, with one line. The stream's queue never waits on a drawable.
- A change of who draws picks the source again for a keyframe; a change of filter only draws the
  last frame again.
- StreamShader: the Metal source, compiled when the renderer starts. PictureSmoothing: the levels,
  the kernels and a CPU reference resampler. pbxproj B21–B23.
- DEBUG -SillSmoothingAt '<s> <level>[; …]' changes the level as the picker does.

Checks: Tests/checks/picture-smoothing, 45 checks of the model and reference and 65 of the shader on
this Mac's GPU against the reference, pixel for pixel (every filter, across and down, growing,
shrinking and the same size; it passes, saying so, without a Metal device), 16 of 16 mutants of
both files; in CI's mutants matrix; run-all.sh all passed. On an iPad Air simulator (iOS 27.0)
against SillHost --synthetic with a still picture of 8-pixel stripes: in-between greys Automatic
7.8 %, None 0.0 %, Low 7.8 %, Medium and High 7.0 %; switching Automatic, None, High, Automatic
mid-stream, the picture back after each switch. Not measured: latency against the system layer,
power, and a device.
Found in the review before this push: every frame that failed to decode printed a line, which for
a stream the decoder cannot read is 60 lines a second in the log. Now once per stream (reset with
each new format).
…, clear to black

Review findings 1–5 on PR Saffsanity#107:
1. iOS invalidates a hardware VTDecompressionSession in the background (-12903 / -12911).
   MetalStreamRenderer now stores the format description, recreates the session when broken
   (PictureSmoothing.sessionBroken), and asks the Mac for a keyframe at most once per 2 s while
   the app is active (PictureSmoothing.DecoderRecovery).
2. setFormat and clear invalidate the old session before clearing `last`, and clear the
   CAMetalLayer with a Metal clear pass (`drawBlack`) so the old window does not stay frozen
   on screen and `layer.contents` is never overwritten.
3. Synchronous decode on the stream's serial queue (`flags: [._1xRealTimePlayback]`) so frames
   draw in strict arrival order; layout and filter redraws hop to that same queue so
   `nextDrawable()` never blocks the main thread and `drawableSize` is updated in order, with
   `layer.colorspace` set on main.
4. `HEVCDisplayView.didMoveToWindow()` marks layout dirty so a saved smoothing level on a 3×
   iPhone updates from the initial 2× fallback scale.
5. Removed the stray blank line in `PictureSmoothing.swift`.

Checks: picture-smoothing 52 + 65 checks and 22 of 22 mutants (6 new for sessionBroken and
DecoderRecovery); live simulator switch test (Automatic → None → High → Automatic) verified.
# Conflicts:
#	.github/workflows/ci.yml
#	iOSClient/StreamClient.swift
# Conflicts:
#	.github/workflows/ci.yml
#	Tests/checks/README.md
#	iOSClient/HostSettingsPanel.swift
@Saffsanity

Copy link
Copy Markdown
Owner

From a pass over all the open pull requests (nothing is decided yet): #93 also adds a keyframe re-pick for a decoder interrupted in the background (KeyframeRecovery) and edits the end of HEVCDisplayView.enqueue, so one recovery mechanism should serve both paths, with whichever lands second reusing the first. #96's Capture Screen and #117's tile previews take drawHierarchy snapshots that have not been tried with this PR's CAMetalLayer, and #66 (with #72 and #73) moves the enqueue path into a shared HEVCSampleFeed that its Mac client compiles; the rest (#94, #95, #99) are the ci.yml line, the checks README and panel rows.

@addhumal

addhumal commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks. Pushed 7c7651e (and merged main in 63c7c28): HEVCDisplayView now exposes the same var onNeedsKeyframe: (() -> Void)? hook as #93 and routes MetalStreamRenderer's keyframe request through it in StreamClient.swift, and MetalStreamRenderer sets layer.framebufferOnly = false so drawHierarchy in #96 and #117 can read the Metal drawable.

Saffsanity added a commit to addhumal/sill that referenced this pull request Oct 19, 2026
…ked again

Review of this PR (the triage of 2026-10-16 and 2026-10-19): the recovery picked the source again
every 2 s, which restarts every device's stream and raises the window in regular mode, for ever if
a layer kept failing. Now the device asks for one keyframe (kind 44), once per interruption.

HEVCDisplayView.checkLayer, on the network queue, flushes a layer that failed (`status`, `error`),
could not decode a frame (its failedToDecodeNotification) or needs a flush to decode again
(requiresFlushToResumeDecoding, and its change notification): at each frame let through, at each
notification (a still window sends no frame that would show it) and when the picture's window
becomes active. It says so (`onNeedsKeyframe`, the hook Saffsanity#107's renderer calls too), and a keyframe
that ends the wait on a healthy layer says the picture is back (`onPictureBack`). A decode failure
posted before the picture last came back is about an earlier frame and is ignored, or it would
throw the new picture away. The Mac sends its parameter sets before every keyframe and each sample
carries the format, so nothing else is sent again.

KeyframeRecovery (pure, main thread) decides when StreamClient sends the ask: one per
interruption, owed again after a trip away (a keyframe that comes meanwhile is lost to it); only
while the picture's window scene is foreground active (each Sill window has its own client) and
something streams; at least 2 s after the last ask, an ask held back going when it is due; a
picture that fails again within 10 s of its keyframe, Sill active throughout, waits 4, 8, 16, then
32 s, so a decoder that cannot keep up never costs every device a keyframe every 2 s; a trip away
or 10 s back ends that. Tests/checks/keyframe-recovery: 51 checks and 20,000 random sessions
against a model of the event history, 28 of 28 mutants. It found a real fault on the way: an ask
held back until `last + spacing` was held again at that very time, (last + s) - last rounding
below s; it compares with the due time now.

DEBUG: the console's "display: …" and "keyframe: …" lines, and -SillLayerFail <s>[,<s>…], which
enqueues a frame that cannot decode at those seconds after the first picture (ContentView's
harness contract).

Live, on a private iPad simulator (iOS 27.0) against a loopback synthetic host on the software
encoder whose Desktop is still from 3 s: -SillLayerFail 6 gave the layer's own -11821, one ask,
one "Keyframe for …" on the host, the picture back 75 to 108 ms after, and a single "Synthetic
capture" line (nothing picked again); -SillLayerFail 6,6.6,12 asked at once, then 4.2 and 8.1 s
after the ask before, three host lines. A trip to the background does not stop the simulator's
layer: nothing was asked and the picture was as it was (a device has to show that case).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saffsanity added a commit to addhumal/sill that referenced this pull request Oct 19, 2026
CLAUDE.md: the Current step entry for this pull request (what was kept, what replaced the re-pick,
kind 44, the rule, the verification, the note for Saffsanity#107 and Saffsanity#66, and what is left for a device);
the Layout (kind 44, the coordinator's answer, WindowSizer's CommandOutcome, KeyframeRecovery,
HEVCDisplayView's checkLayer, SILL_TEST_STILL_AFTER, the fixture's Slow Close window and the close
step, sillclient's --keyframe@T, the check); Build and run (the still host, --keyframe@T,
-SillLayerFail); and kind 44 among the compatibility floor's kinds, empty and skipped by a host
from before.

docs/DEVELOPMENT.md: the picture's recovery under "If the picture freezes" (the host's line, the
rule, an older Mac), the lights and Close's timeout after "Windows sized for a device", the menus'
test app's Slow Close window and the close step (and that menu-check needs an unlocked screen),
-SillLayerFail in the debug harness, the still host among the CLI's test variables, and
--keyframe@T beside the test client's flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

Feature Request: Picture Smoothing options (None / Low / Medium / High) for scaled streams

2 participants