diff --git a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift index 2bebfb0e..01d39006 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift @@ -602,6 +602,36 @@ public final class MetalVideoPresenter { } #endif + /// Deadline pacing only, RENDER THREAD: reconcile the layer with a decoded frame BEFORE a + /// drawable exists. The link vends from the layer's CURRENT config, and the layer starts + /// with `drawableSize` 0 (it never tracks bounds once set explicitly, and the sublayer's + /// frame isn't even laid out when the link spins up) — so leaving all reconciliation to the + /// render path (which needs a frame AND a vended drawable) deadlocks at session start: + /// every vend fails allocation at 0×0, the stash stays empty, no pair ever completes, and + /// the size is never set. The 2026-07-19 iPad black screen ("[CAMetalLayer nextDrawable] + /// returning nil because allocation failed" every refresh). Called on EVERY frame arrival: + /// drains the same staging the render path drains (both are idempotent about it) and + /// applies size + HDR config, so the next vend always matches the frame about to present — + /// this also makes a mid-session HDR flip cost at most one skipped vend instead of waiting + /// for a paired present to retag the layer. + func reconcileLayer(decodedSize: CGSize, isHDR: Bool) { + stagingLock.lock() + let targetFromLayout = drawableTarget + let newHdrMeta = pendingHdrMeta + pendingHdrMeta = nil + stagingLock.unlock() + configure(hdr: isHDR) + if let newHdrMeta { + self.lastHdrMeta = newHdrMeta + #if !os(tvOS) + if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) } + #endif + } + let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0) + ? targetFromLayout : decodedSize + if layer.drawableSize != targetSize { layer.drawableSize = targetSize } + } + /// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's; /// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR` /// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 500db1f6..113ac239 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -813,19 +813,35 @@ public final class Stage2Pipeline { debugStats?.flushIfDue(ring: ring, gate: nil) return } - // Present needs the PAIR. Take the drawable first: if it's missing, the frame - // stays in the ring untouched (coalescing newest-wins) for the link's next - // update — a wait bounded by one refresh. - guard !token.isStopped, let drawable = stash.take() else { - debugStats?.noDrawableWake() + // Present needs the PAIR — frame first. The frame drives the layer reconcile, + // which must run even when NO drawable is vended yet: the link vends from the + // layer's CURRENT config, so drawableSize/format have to be right before a vend + // can succeed at all (see reconcileLayer — the session-start bootstrap, where + // the layer still has its initial 0×0 size and every vend fails allocation). + guard !token.isStopped, let frame = ring.take() else { + debugStats?.emptyWake() debugStats?.flushIfDue(ring: ring, gate: nil) return } - guard let frame = ring.take() else { - // No frame yet — return the drawable for the next arrival. putBack, not put: - // the link may have vended a fresher drawable in between, and that one wins. - stash.putBack(drawable) - debugStats?.emptyWake() + switch frame.image { + case .video(let pixelBuffer, let isHDR): + presenter.reconcileLayer( + decodedSize: CGSize( + width: CVPixelBufferGetWidth(pixelBuffer), + height: CVPixelBufferGetHeight(pixelBuffer)), + isHDR: isHDR) + case .planar(let planes): + presenter.reconcileLayer( + decodedSize: CGSize(width: planes.width, height: planes.height), + isHDR: planes.pq) + } + guard let drawable = stash.take() else { + // No vend yet (session start: the reconcile above just unblocked the + // allocator, the link's next update delivers; steady state: decode beat the + // link's phase). putBack keeps newest-wins — a fresher decode replaces this + // frame while it waits, and the update's signal retries the pairing. + ring.putBack(frame) + debugStats?.noDrawableWake() debugStats?.flushIfDue(ring: ring, gate: nil) return }