Files
punktfunk/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift
T
enricobuehler 2149673f89
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m52s
release / apple (push) Successful in 8m46s
deb / build-publish (push) Successful in 11m40s
docker / deploy-docs (push) Successful in 24s
arch / build-publish (push) Successful in 12m42s
deb / build-publish-host (push) Successful in 12m6s
android / android (push) Successful in 13m55s
apple / screenshots (push) Successful in 6m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m39s
ci / rust (push) Successful in 24m30s
ci / docs-site (push) Successful in 50s
ci / web (push) Successful in 54s
apple / swift (push) Successful in 1m15s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
fix(apple): default iOS to glass-gated present pacing with a depth-2 gate — the 23 ms display-stage fix
Field report (iPad Pro 13" M4, 2752x2064@120): display 23.1 ms on the default
arrival pacing vs 14 ms glass-gated, dominating an otherwise ~14 ms pipeline.
On iOS the layer ALWAYS vsync-latches (displaySyncEnabled is macOS-only API)
and default-on VRR steers the panel to the stream rate, so arrival pacing's
sticky-FIFO saturation (~2-3 refreshes of queue) is the common case, not the
corner — the exact regime that made tvOS default to glass.

- PresenterChoice.platformDefault -> stage3 on iOS/iPadOS (joins tvOS);
  explicit stage-2 stays the honest arrival A/B; macOS unchanged.
- PresentGate generalized to a capacity: depth 1 is bit-identical to before;
  iOS runs depth 2 (one flip scanning out + one queued for the next latch),
  so a decoded frame presents immediately and latches the very next vsync
  instead of serializing on the previous flip's on-glass callback — expected
  ~5-8 ms at 120 Hz. tvOS keeps depth 1 (proven; A/B first), macOS is pinned
  to 1 (glass there is the DCP swapID-panic mitigation — serialization is
  its point). PUNKTFUNK_GATE_DEPTH (1-3) is the on-device A/B lever.
- Settings picker derives its "(default)" marker from presenterDefault
  instead of hardcoding stage-2 as default / stage-3 as experimental.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:42:34 +02:00

401 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline
// (explicit VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's
// CADisplayLink) is the default — glass-paced stage-3 on tvOS + iOS, arrival-paced stage-2 on
// macOS (see PresenterChoice.platformDefault); stage-1 (StreamPump → AVSampleBufferDisplayLayer)
// is the Metal-unavailable / DEBUG fallback. The views own the platform bits — capture,
// window/scale tracking, and constructing the display link — and delegate the shared presenter
// lifecycle here.
//
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
#if canImport(Metal) && canImport(QuartzCore)
import AVFoundation
import Foundation
import PunktfunkShared
import QuartzCore
#if os(tvOS)
import UIKit
#endif
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or
/// presenter directly makes a `owner → link → owner` cycle that only `invalidate()` breaks — if a
/// teardown is ever missed the owner leaks and keeps ticking. The proxy is what the link retains;
/// the handler closure captures the owner `[weak]`, so the owner can deallocate and its `deinit`
/// invalidate the link.
public final class DisplayLinkProxy: NSObject {
private let onTick: (CADisplayLink) -> Void
public init(_ onTick: @escaping (CADisplayLink) -> Void) { self.onTick = onTick }
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
}
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
/// glass-gated present pacing (`PresentPacing` — see Stage2Pipeline for the tradeoff, and why
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
enum PresenterChoice: Equatable {
case stage1
case stage2
case stage3
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
/// falls back to the platform default. `allowStage1` is false in release builds, where a
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
/// freeze-prone fallback.
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
explicit(setting: setting, env: env, allowStage1: allowStage1) ?? platformDefault
}
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
/// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default
/// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage — an
/// explicit "stage2" must stay a faithful A/B of arrival pacing.
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw {
case "stage1": return allowStage1 ? .stage1 : nil
case "stage2": return .stage2
case "stage3": return .stage3
default: return nil
}
}
/// tvOS and iOS/iPadOS default to GLASS pacing: their layers ALWAYS vsync-latch presents into
/// the FIFO image queue (`displaySyncEnabled` is macOS-only API), so whenever the panel runs
/// near the stream rate — an Apple TV's fixed 60 Hz fed a 60 fps stream by construction; an
/// iPhone/iPad fed a stream at the panel rate, which VRR (default on, preferred = stream rate)
/// makes the COMMON case — arrival pacing pins the queue at ~`maximumDrawableCount` and every
/// frame rides ~23 refreshes of it (measured: ~50 ms on Apple TV; 2330 ms at 120 Hz on
/// ProMotion iPads — the 2026-07 iPad Pro 2752×2064@120 field report read display 23.1 ms on
/// arrival vs 14 ms glass). The Settings picker can still force stage-2 for an A/B. macOS
/// keeps stage-2: with the layer's sync off, presents are out-of-band flips that don't queue,
/// so arrival is genuinely lowest-latency there.
static var platformDefault: PresenterChoice {
#if os(tvOS) || os(iOS)
.stage3
#else
.stage2
#endif
}
}
final class SessionPresenter {
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
/// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole
/// machine dies) when WindowServer's swap submissions race, and the reliable trigger is
/// out-of-band CAMetalLayer presents (displaySyncEnabled=false — mandatory for us, see
/// MetalVideoPresenter's init) arriving faster than the compositor latches them in a
/// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet
/// decode is near-instant Metal compute, so a network clump of frames presents within the
/// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's
/// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on
/// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright;
/// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit
/// stage-2 pick (setting/env) still forces arrival pacing — that A/B lever must stay honest.
/// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years
/// of stage-2 defaults there predate any panic report.
static func pacing(
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
) -> PresentPacing {
if choice == .stage3 { return .glass }
#if os(macOS)
if explicit == nil, codec == .pyrowave { return .glass }
#endif
return .arrival
}
/// The glass gate's in-flight present budget (`PresentGate` capacity) for this platform.
///
/// - iOS/iPadOS: 2 — one flip scanning out plus one queued for the next latch. A decoded
/// frame presents immediately (the gate is open in steady state) and latches the very next
/// vsync, so the present cadence never serializes on the on-glass callback's own latency —
/// depth 1's extra-refresh cost (the field-measured 14 ms display stage at 120 Hz, vs a
/// ~half-refresh floor). The queue still can't build past two flips, so arrival pacing's
/// FIFO saturation (2330 ms) stays gone.
/// - tvOS: 1 — the proven fixed-60-Hz config. Depth 2 should shorten its display stage the
/// same way but is unmeasured there; A/B first (`PUNKTFUNK_GATE_DEPTH=2`), then flip.
/// - macOS: pinned to 1, env ignored — glass pacing exists there as the DCP swapID
/// kernel-panic mitigation (see `pacing`), and STRICT present serialization is its point.
///
/// `PUNKTFUNK_GATE_DEPTH` (1…3) overrides on iOS/tvOS for on-device A/B, mirroring the other
/// presenter env levers. Internal (not private) for unit tests.
static func gateDepth(env: String?) -> Int {
#if os(macOS)
return 1
#else
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
#if os(tvOS)
return 1
#else
return 2
#endif
#endif
}
private var pump: StreamPump?
private var stage2: Stage2Pipeline?
private var stage2Link: CADisplayLink?
private var metalLayer: CAMetalLayer?
#if os(macOS)
/// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last
/// routing pushed to the pipeline — see `setComposited`. Main-thread only, like all of this.
private var surfaceLayer: CALayer?
private var surfacePresentsActive = false
#endif
private var connection: PunktfunkConnection?
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
/// `connection.currentMode()`, which (a) lags a mid-stream resize — it only updates on the
/// `Reconfigured` ack, and a resize-END produces no bounds change to re-run `layout` afterward —
/// and (b) can disagree with what the host actually DELIVERED (Windows corrective-ack falls back
/// to an advertised mode). The pixels we're drawing are the only correct aspect source; a wrong
/// one here is the "black bars + stretched" resize artifact. nil until the first frame → `layout`
/// falls back to `currentMode()`. Main-thread only.
private var contentSize: CGSize?
/// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer:
/// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer
/// sublayer over it. `makeDisplayLink` supplies the platform link (macOS `NSView.displayLink`
/// tracks the view's display; iOS/tvOS uses the plain `CADisplayLink` init) — only called when
/// stage-2 engages. Call `layout(in:contentsScale:)` right after so the sublayer has a frame
/// before the first tick.
func start(
connection: PunktfunkConnection,
baseLayer: AVSampleBufferDisplayLayer,
endToEndMeter: LatencyMeter?,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil,
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) {
stop()
self.connection = connection
// Presenter choice — the Metal pipeline is the DEFAULT (explicit VTDecompressionSession
// decode + a CAMetalLayer/display-link present): it can detect + recover a wedged decoder
// where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Which
// pacing it defaults to is per-platform (glass-gated stage-3 on tvOS/iOS, arrival stage-2
// on macOS — see PresenterChoice.platformDefault); the settings picker is the live A/B.
// Stage-1 is reachable only via the DEBUG presenter value; release maps it back to the
// default (the stage-1 pump below stays the automatic fallback if Metal is missing).
#if DEBUG
let allowStage1 = true
#else
let allowStage1 = false
#endif
let explicit = PresenterChoice.explicit(
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
allowStage1: allowStage1)
let choice = explicit ?? PresenterChoice.platformDefault
if choice != .stage1,
let pipeline = Stage2Pipeline(
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter,
pacing: Self.pacing(
for: choice, explicit: explicit, codec: connection.videoCodec),
gateDepth: Self.gateDepth(
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"])) {
let metal = pipeline.layer
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
baseLayer.addSublayer(metal)
metalLayer = metal
#if os(macOS)
// The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil
// contents) while the metal path presents, covering it while surface presents run.
baseLayer.addSublayer(pipeline.surfaceLayer)
surfaceLayer = pipeline.surfaceLayer
surfacePresentsActive = false
#endif
stage2 = pipeline
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
// link's own report of the current refresh period (tracks VRR rate changes).
let proxy = DisplayLinkProxy { [weak self] link in
self?.stage2?.renderTick(
targetMediaTime: link.targetTimestamp,
period: link.targetTimestamp - link.timestamp)
}
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
link.add(to: .main, forMode: .common)
stage2Link = link
syncFrameRate(hz: connection.currentMode().refreshHz)
pipeline.start(
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
onDecodedSize: onDecodedSize)
} else {
let pump = StreamPump()
pump.start(
connection: connection, layer: baseLayer,
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
self.pump = pump
}
}
/// Hint the display link with the stream's cadence. On iOS/tvOS a range is always required:
/// without one, ProMotion devices cap CADisplayLink at 60 Hz (iPhones additionally need
/// `CADisableMinimumFrameDurationOnPhone` in Info.plist), so a 120 fps stream would present
/// at half rate with the ring silently dropping every other frame. `maximum` allows up to 120
/// so the system MAY tick faster than a sub-120 stream (each extra tick is a near-free empty
/// `renderTick`, and presenting on a denser grid shortens the decode→glass wait).
///
/// The `allowVRR` setting (default on) widens that hint into a true variable-refresh request:
/// `preferred` = the stream rate with a low floor, so a ProMotion / adaptive-sync display can
/// drop its physical refresh to match the content. With VRR off we fall back to the proven
/// behavior — iOS keeps a 30 Hz floor; macOS leaves the NSView link at its display's native
/// rate (it already tracks the display and must NOT be capped to the stream rate).
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
private func syncFrameRate(hz: UInt32) {
guard hz > 0, let link = stage2Link else { return }
let hzF = Float(hz)
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
#if os(macOS)
// Off: `.default` = the link free-runs at the display's native rate (pre-VRR behavior).
// On: request the content rate with a 24 Hz floor — capped at the display, never at the
// stream rate, so an adaptive-sync panel can track the stream.
let range: CAFrameRateRange = allowVRR
? CAFrameRateRange(minimum: min(hzF, 24), maximum: max(hzF, 120), preferred: hzF)
: .default
#else
// A range is mandatory here (see above); VRR only lowers the floor (24 vs 30) so the
// panel can drop deeper to match content on a sub-rate or momentarily stalling stream.
let floor = allowVRR ? min(hzF, 24) : min(hzF, 30)
let range = CAFrameRateRange(minimum: floor, maximum: max(hzF, 120), preferred: hzF)
#endif
if link.preferredFrameRateRange != range { link.preferredFrameRateRange = range }
}
/// Position the stage-2 metal sublayer aspect-fit in the hosting view (the host streams at the
/// client's native mode, so this is usually the full bounds; it letterboxes a resized window).
/// The layer FRAME + contentsScale set here are what the presenter sizes its drawable from
/// (frame × scale) — the shader then performs the decoded→on-screen scale (bicubic luma), so a
/// native-mode session stays pixel-exact 1:1 and a mismatched window beats the compositor's
/// bilinear. No-op for stage-1 or before start.
func layout(in bounds: CGRect, contentsScale: CGFloat) {
guard let metalLayer, let connection else { return }
let mode = connection.currentMode()
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
// Aspect source: the ACTUAL decoded dims when known (survives a lagging `currentMode()` and a
// host that delivered a different size than requested), else the negotiated mode. The shader
// stretches the frame across the WHOLE drawable, so this rect's aspect is the only thing that
// keeps the picture undistorted — a stale aspect here is the post-resize black-bars+stretch.
let aspect: CGSize? = {
if let c = contentSize, c.width > 0, c.height > 0 { return c }
if mode.width > 0, mode.height > 0 {
return CGSize(width: Int(mode.width), height: Int(mode.height))
}
return nil
}()
let fit: CGRect = aspect.map { AVMakeRect(aspectRatio: $0, insideRect: bounds) } ?? bounds
// Snap the sublayer frame to the BACKING PIXEL GRID. AVMakeRect centers the aspect-fit rect,
// so its origin/size are usually fractional points; a metal sublayer whose frame doesn't land
// on whole device pixels is RESAMPLED by the macOS/UIKit compositor during composite — a
// uniform "everything looks soft" blur — even when the drawable itself is pixel-exact 1:1
// (verified via the stage2 "[1:1 (no resample)]" log while the picture was still soft). Round
// origin AND size to device pixels so the composite is a true 1:1 blit. Idempotent when the
// frame is already aligned (e.g. fullscreen fit == integer bounds), so it's a no-op there.
let scale = contentsScale > 0 ? contentsScale : 1
let snapped = CGRect(
x: (fit.origin.x * scale).rounded() / scale,
y: (fit.origin.y * scale).rounded() / scale,
width: (fit.width * scale).rounded() / scale,
height: (fit.height * scale).rounded() / scale)
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
CATransaction.begin()
CATransaction.setDisableActions(true)
metalLayer.contentsScale = contentsScale
metalLayer.frame = snapped
#if os(macOS)
// The surface present target mirrors the metal layer's geometry exactly — its IOSurfaces
// are sized to the same snapped pixel rect, so the contents composite is a 1:1 blit too.
surfaceLayer?.contentsScale = contentsScale
surfaceLayer?.frame = snapped
#endif
CATransaction.commit()
// Hand the resulting pixel size to the render thread (it must not read layer geometry
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
// the drawable's texel count equals the on-screen device-pixel count exactly (1 texel ↔ 1
// device pixel); with the frame snapped, this equals the pre-snap rounded value, so the
// decoded↔drawable 1:1 the log confirmed is preserved.
stage2?.setDrawableTarget(CGSize(
width: (snapped.width * scale).rounded(),
height: (snapped.height * scale).rounded()))
#if os(tvOS)
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
// HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR
// frames flip to PQ passthrough. The stream view also re-layouts on mode-switch/screen-
// mode notifications, so a mid-session switch reaches here without a bounds change.
stage2?.setDisplayHeadroom(UIScreen.main.currentEDRHeadroom)
#endif
}
/// Record the decoded frame's real dimensions (the view hops the pump's `onDecodedSize` to main
/// and calls this) so `layout` aspect-fits to what's actually on screen instead of the possibly-
/// stale `currentMode()`. Only stores — the caller re-runs `layout` right after, because a
/// resize-END produces no bounds change to trigger one. No-op on a zero/unchanged size.
func setContentSize(_ size: CGSize) {
guard size.width > 0, size.height > 0, size != contentSize else { return }
contentSize = size
}
#if os(macOS)
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
/// every layout, which fullscreen transitions always trigger). PyroWave sessions in a
/// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the
/// CAMetalLayer image queue — the DCP "mismatched swapID's" kernel-panic mitigation (see
/// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing
/// alone was not enough). VT codecs keep the metal path: no panic reports there, and their
/// HDR/EDR presentation has no surface-contents equivalent wired.
func setComposited(_ composited: Bool) {
guard let stage2, let connection else { return }
let wantsSurface = composited && connection.videoCodec == .pyrowave
guard wantsSurface != surfacePresentsActive else { return }
surfacePresentsActive = wantsSurface
stage2.setSurfacePresents(wantsSurface)
if !wantsSurface {
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
// entry shows the previous frame until the next present — no black flash).
CATransaction.begin()
CATransaction.setDisableActions(true)
surfaceLayer?.contents = nil
CATransaction.commit()
}
}
#endif
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
/// Idempotent.
func stop() {
contentSize = nil // a new session re-derives it from its first frame
pump?.stop()
pump = nil
stage2Link?.invalidate()
stage2Link = nil
stage2?.stop() // stops the pump (synchronous join) + drops the decode session
stage2 = nil
metalLayer?.removeFromSuperlayer()
metalLayer = nil
#if os(macOS)
surfaceLayer?.removeFromSuperlayer()
surfaceLayer = nil
surfacePresentsActive = false
#endif
connection = nil
}
deinit {
// The owning view's stop() normally ran already; this covers a missed teardown so the
// display link can't keep ticking a deallocated pipeline.
stage2Link?.invalidate()
stage2?.stop()
pump?.stop()
}
}
#endif