diff --git a/Sources/AetherEngine/AetherEngine+Diagnostics.swift b/Sources/AetherEngine/AetherEngine+Diagnostics.swift index 31723db..b8a87fc 100644 --- a/Sources/AetherEngine/AetherEngine+Diagnostics.swift +++ b/Sources/AetherEngine/AetherEngine+Diagnostics.swift @@ -213,6 +213,12 @@ extension AetherEngine { softwareHost?.framesEnqueued ?? 0 } + /// Public mirror for host stats overlays: on the SW-decode path (no AVPlayer access log) it's the + /// only rendered-frame counter, so a host derives an honest rendered-fps from its delta. + public var softwareFramesEnqueued: Int { + softwareHost?.framesEnqueued ?? 0 + } + /// Producer restart count for the current session. Zero on SW path or pre-start. var producerRestartCount: Int { nativeVideoSession?.producerRestartCount ?? 0 diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 0e4b14d..b6840f6 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -719,6 +719,11 @@ public final class AetherEngine: ObservableObject { /// Native AVPlayer + AVPlayerLayer host. Non-nil between load and stop. var nativeHost: NativeAVPlayerHost? + /// The on-screen `AVPlayerLayer` of the native (AVPlayer) path; nil on the software path or when + /// idle. Exposed so a host PiP controller can drive `AVPictureInPictureController` with the ACTUAL + /// presented layer (correct shrink/expand transition origin). tvOS: PiP is only on this native path. + public var nativePlayerLayer: AVPlayerLayer? { nativeHost?.playerLayer } + /// Combine subscriptions mirroring nativeHost's @Published into the engine. Cancelled in stopInternal. var nativeCancellables: Set = [] diff --git a/Sources/AetherEngine/Decoder/SoftwareVideoDecoder.swift b/Sources/AetherEngine/Decoder/SoftwareVideoDecoder.swift index 84ca750..9e26a66 100644 --- a/Sources/AetherEngine/Decoder/SoftwareVideoDecoder.swift +++ b/Sources/AetherEngine/Decoder/SoftwareVideoDecoder.swift @@ -508,6 +508,10 @@ final class SoftwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { /// seen carrying 1088:1 on live TS; no legitimate pixel aspect ratio needs values above 256. static func saneSAR(_ sar: AVRational) -> AVRational? { guard sar.num > 0, sar.den > 0, sar.num <= 256, sar.den <= 256 else { return nil } + // Reject an implausibly extreme pixel aspect (e.g. a bogus 3:1 that stretches 1920x1080 into a + // 5760x1080 strip). No real PAR stretches a frame more than ~2.5x on either axis. + let ratio = Double(sar.num) / Double(sar.den) + guard ratio <= 2.5, ratio >= 1.0 / 2.5 else { return nil } return sar } diff --git a/Sources/AetherEngine/Display/DisplayCriteriaController.swift b/Sources/AetherEngine/Display/DisplayCriteriaController.swift index a03b191..60046ad 100644 --- a/Sources/AetherEngine/Display/DisplayCriteriaController.swift +++ b/Sources/AetherEngine/Display/DisplayCriteriaController.swift @@ -187,6 +187,16 @@ final class DisplayCriteriaController { let displayManager = window.avDisplayManager let screen = window.screen + // Fast exit: Match Content is off, so tvOS will not change the HDMI mode for any content and + // `apply()` above has already returned `.applied` without writing criteria. Nothing can start + // a switch, so Stage 1's blind 1000ms is pure startup latency added to every load — visible in + // a field log as `skipped: Match Content disabled` followed one second later by + // `no switch started (EDR headroom 1.00 after 1000ms)`. + guard displayManager.isDisplayCriteriaMatchingEnabled else { + EngineLog.emit("[DisplayCriteria] no wait: Match Content disabled (nothing can switch)", category: .engine) + return + } + // Fast exit: panel already in HDR (headroom already raised, e.g. a prior // HDR/DV session left it there). if screen.currentEDRHeadroom > 1.001 {