diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 653837f7..65d27c99 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -719,7 +719,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.9.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -764,7 +764,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.9.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index b1996ec8..352a60b4 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -975,28 +975,39 @@ public final class Stage2Pipeline { let stash = LatestBox() let floorMeter = presentFloorMeter - let linkThread = Thread { - let delegate = DeadlineLinkDelegate( - stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats, - floorMeter: floorMeter) - 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 } - link.delegate = delegate // weak — this closure is the strong ref - link.add(to: RunLoop.current, forMode: .default) - while !token.isStopped { - autoreleasepool { - _ = RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1)) + // The link starts LAZILY — the render thread triggers this after the FIRST decoded + // frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0 + // drawableSize for the whole connect window: every vend fails allocation and the system + // logs "[CAMetalLayer nextDrawable] returning nil because allocation failed" once per + // refresh until the first frame arrives. Before that frame there is nothing to present + // anyway, and the first frame waits at most one refresh for the first vend. + let startLink: () -> Void = { + let linkThread = Thread { + let delegate = DeadlineLinkDelegate( + stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats, + floorMeter: floorMeter) + let link = CAMetalDisplayLink(metalLayer: layer) + link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh + if let range = hint.drain() { link.preferredFrameRateRange = range } + link.delegate = delegate // weak — this closure is the strong ref + link.add(to: RunLoop.current, forMode: .default) + while !token.isStopped { + autoreleasepool { + _ = RunLoop.current.run( + mode: .default, before: Date(timeIntervalSinceNow: 0.1)) + } } + link.invalidate() } - link.invalidate() + linkThread.name = "punktfunk-stage4-link" + linkThread.qualityOfService = .userInteractive + linkThread.start() } - linkThread.name = "punktfunk-stage4-link" - linkThread.qualityOfService = .userInteractive - linkThread.start() let renderThread = Thread { defer { renderStopped.signal() } + // Whether startLink ran — render-thread confined (only this thread triggers it). + var linkLive = false // Per-iteration autorelease pool — same contract as the arrival/glass loop (the // vended drawable and its retinue are autoreleased objects on a runloop-less thread). while !token.isStopped { autoreleasepool { @@ -1026,6 +1037,11 @@ public final class Stage2Pipeline { decodedSize: CGSize(width: planes.width, height: planes.height), isHDR: planes.pq) } + // First frame: the layer now has a real config — start vending (see startLink). + if !linkLive { + linkLive = true + startLink() + } 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 diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift index 7d0112ae..bdd96350 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift @@ -65,19 +65,21 @@ public enum Stage444Probe { guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false } var produced: OSType = 0 - let done = DispatchSemaphore(value: 0) + // SYNCHRONOUS decode — no `._EnableAsynchronousDecompression`, so the output callback + // runs on THIS thread before DecodeFrame returns. The async flag + semaphore wait it + // replaced tripped the Thread Performance Checker on every first connect: VideoToolbox's + // callback thread carries no QoS class, and the userInteractive connect Task blocked on + // it through the semaphore (a priority inversion). A one-shot 256×256 probe gains + // nothing from decode parallelism; the lazy statics still cache the result. let status = VTDecompressionSessionDecodeFrame( session, sampleBuffer: sample, - flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil + flags: [], infoFlagsOut: nil ) { status, _, imageBuffer, _, _ in if status == noErr, let imageBuffer { produced = CVPixelBufferGetPixelFormatType(imageBuffer) } - done.signal() } guard status == noErr else { return false } - VTDecompressionSessionWaitForAsynchronousFrames(session) - _ = done.wait(timeout: .now() + 1.0) return produced == want || produced == fullRangeSibling } }