diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index f2da190f..caed5c18 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -89,6 +89,11 @@ struct ContentView: View { /// the remote-as-pointer controls, so it must be seen at least once per session. @State private var showShortcutHint = false #endif + #if os(iOS) + /// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)` + /// — the disc must LEAVE the hierarchy so nothing composites over the metal layer). + @State private var showTouchExit = false + #endif #if !os(macOS) @State private var showSettings = false #endif @@ -193,6 +198,9 @@ struct ContentView: View { #if os(macOS) || os(tvOS) showShortcutHint = true // the 6 s shortcut banner, per session start #endif + #if os(iOS) + showTouchExit = true // the off-tier exit disc's 8 s window, per session start + #endif // A session actually started — remember it on the card ("Connected … ago" // plus the accent ring on the most recent host). guard let host = model.activeHost else { break } @@ -636,18 +644,27 @@ struct ContentView: View { #if os(iOS) // Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on // screen — the overlay off, or the compact pill (which carries no button) — - // keep a minimal always-reachable exit in a corner. It rides a material disc - // (like the HUD) so the glyph stays legible over a bright frame — this is the - // sole touch disconnect path in those tiers. + // keep a minimal touch exit in a corner. It rides a material disc (like the + // HUD) so the glyph stays legible over a bright frame. + // + // In the OFF tier the disc shows for the first 8 s of a session, then leaves + // the hierarchy ENTIRELY (the shortcut-banner pattern): any composited overlay + // above the stream — a glass one doubly so, its blur SAMPLES the video layer — + // forces the CAMetalLayer through the compositor, costing ~a refresh of display + // latency and blocking direct-to-display promotion. Off is the immersive/ + // measurement tier; after the fade, touch-only exits are backgrounding the app + // or re-enabling the stats overlay. Compact keeps its disc permanently — that + // tier composites a HUD pill anyway, so hiding the exit there wins nothing. .overlay(alignment: .topLeading) { - if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) { + if captureEnabled, + statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) { Button { model.disconnect() } label: { Image(systemName: "xmark") .font(.headline.weight(.semibold)) .frame(width: 36, height: 36) - // Sole touch exit in the off/compact tiers — a floating glass disc - // over the frame (26+, material fallback). interactive: the disc - // IS the tap target, so the glass reacts to press. + // Floating glass disc over the frame (26+, material fallback). + // interactive: the disc IS the tap target, so the glass reacts + // to press. .glassBackground(Circle(), interactive: true) // Match the hit region to the visible disc so every tap also // triggers the interactive-glass press highlight. @@ -656,6 +673,12 @@ struct ContentView: View { .buttonStyle(.plain) .padding(12) .accessibilityLabel("Disconnect") + .transition(.opacity) + .task { + guard statsVerbosity == .off else { return } + try? await Task.sleep(for: .seconds(8)) + withAnimation(.easeOut(duration: 0.6)) { showTouchExit = false } + } } } #endif diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 113ac239..466b356e 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -44,6 +44,7 @@ import Foundation import Metal import PunktfunkShared import QuartzCore +import os /// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode /// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call — for @@ -51,6 +52,12 @@ import QuartzCore /// stdout is the cheapest reliable capture channel. let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1" +/// The pf-present line's os_log mirror (subsystem io.unom.punktfunk, category "present") — the +/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition +/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the +/// stats are a few arrays + one log line per second); other pacings keep the env-gated print. +private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present") + /// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest /// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded. private final class ReadyRing: @unchecked Sendable { @@ -208,17 +215,29 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate { private let stash: LatestBox private let renderSignal: DispatchSemaphore private let hint: FrameRateHint + private let stats: PresentDebugStats? - init(stash: LatestBox, renderSignal: DispatchSemaphore, hint: FrameRateHint) { + init( + stash: LatestBox, renderSignal: DispatchSemaphore, + hint: FrameRateHint, stats: PresentDebugStats? + ) { self.stash = stash self.renderSignal = renderSignal self.hint = hint + self.stats = stats } func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) { if let range = hint.drain(), link.preferredFrameRateRange != range { link.preferredFrameRateRange = range } + // Re-assert the minimum-latency request every update (cheap compare): it was set once + // before add(to:), and whether a pre-add set survives scheduling is exactly the kind of + // thing the vendLeadMs stat exists to catch — belt and braces. + if link.preferredFrameLatency != 1 { link.preferredFrameLatency = 1 } + // The link's own pipeline depth, measured: how far ahead of glass this vend runs. + stats?.vendLead( + ms: (update.targetPresentationTimestamp - CACurrentMediaTime()) * 1000) stash.put(update.drawable) renderSignal.signal() } @@ -305,6 +324,11 @@ private final class PresentDebugStats: @unchecked Sendable { /// of it, queue + present-pipeline cost inside it. Standing queue reads as ~n×period here; /// a healthy latch reads under one period. private var latchMs: [Double] = [] + /// Deadline pacing: the link's own pipeline depth — `targetPresentationTimestamp - now` at + /// each update. ~1 period means preferredFrameLatency=1 is honored (a vended drawable can + /// reach glass at the NEXT refresh); ~2 periods means the system is running a frame ahead + /// and one whole refresh of the display stage lives INSIDE the link, not in our pairing. + private var vendLeadMs: [Double] = [] /// Presented-but-not-yet-on-glass drawables right now / the window's peak — the direct /// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a /// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth). @@ -323,6 +347,9 @@ private final class PresentDebugStats: @unchecked Sendable { /// decode outruns the link's phase; the wait is bounded by one refresh. func noDrawableWake() { lock.lock(); noDrawable += 1; lock.unlock() } + /// Deadline pacing, LINK thread: one update's vend-to-target distance (see `vendLeadMs`). + func vendLead(ms: Double) { lock.lock(); vendLeadMs.append(ms); lock.unlock() } + func renderReturned(ok rendered: Bool, tookMs: Double) { lock.lock() if rendered { @@ -361,21 +388,32 @@ private final class PresentDebugStats: @unchecked Sendable { let latches = latchMs.sorted() let latchP50 = latches.isEmpty ? 0 : latches[latches.count / 2] let latchMax = latches.last ?? 0 + let vends = vendLeadMs.sorted() + let vendP50 = vends.isEmpty ? 0 : vends[vends.count / 2] + let vendMax = vends.last ?? 0 let inflightMax = maxInFlight let line = String( format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d " + "dropped=%d maxRenderMs=%.1f inflightMax=%d forced=%d " - + "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f", + + "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f " + + "vendLeadMs p50=%.2f max=%.2f", decoded, ok, failed, empty, gated, noDrawable, dropped, maxRenderMs, inflightMax, - gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax) + gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax, + vendP50, vendMax) ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0 maxRenderMs = 0 maxInFlight = inFlight // the window peak restarts from the live depth glassDeltasMs.removeAll(keepingCapacity: true) latchMs.removeAll(keepingCapacity: true) + vendLeadMs.removeAll(keepingCapacity: true) lock.unlock() - print(line) - fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows + // Console.app first (the on-device readout — see presentLog); stdout only under the env + // lever (the CLI client's capture channel). + presentLog.info("\(line, privacy: .public)") + if presentDebug { + print(line) + fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows + } } } @@ -650,8 +688,9 @@ public final class Stage2Pipeline { // The present half. Deadline pacing (stage-4) swaps it wholesale: a CAMetalDisplayLink // vends the drawables and its per-refresh updates co-drive the render thread — see // startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline- - // times every present). - let debugStats = presentDebug ? PresentDebugStats() : nil + // times every present). Deadline sessions ALWAYS carry the stats (their pf-present line + // streams to Console.app via presentLog — the on-device pacing decomposition). + let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil if pacing == .deadline { startDeadlinePresenter(debugStats: debugStats) return @@ -787,7 +826,7 @@ public final class Stage2Pipeline { let linkThread = Thread { let delegate = DeadlineLinkDelegate( - stash: stash, renderSignal: renderSignal, hint: hint) + stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats) let link = CAMetalDisplayLink(metalLayer: layer) link.preferredFrameLatency = 1 // wake as late as fits: present latches the NEXT refresh if let range = hint.drain() { link.preferredFrameRateRange = range }