From d66321a66b92fc58265c872ea379cab87ae5a584 Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Mon, 19 Oct 2026 06:53:51 +0200 Subject: [PATCH] feat(display): a host-owned display-criteria mode (#842) suppressDisplayCriteria means "AVKit writes the criteria from the item", and every branch behind it is built around that inbound write: the play gate holds the full 1000 ms Stage 1 for HDR and Dolby Vision, the panel readout is skipped as stale, and observation is armed for the write. A host that keeps the panel in its system output format (a Dynamic Range "System" setting) or writes criteria of its own had no correct setting. LoadOptions.displayCriteriaOwner names the writer: .engine (default, defers to suppressDisplayCriteria), .avKit, and .host. A host-owned load never writes, clears only a criteria the engine left applied, takes the brief play-gate budget like an engine write (the write happened before load()), reads the panel, and still waits out a switch it sees running. An explicit owner wins over the Bool; a secondary engine stays a non-writer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WJcXkGQrMiBPixVEFTSBJS --- CHANGELOG.md | 9 ++- .../AetherEngine/AetherEngine+Loading.swift | 8 +-- Sources/AetherEngine/AetherEngine+Probe.swift | 14 ++-- .../AetherEngine+ReloadWithOptions.swift | 2 +- Sources/AetherEngine/AetherEngine.swift | 12 ++-- .../Display/DisplayCriteriaOwner.swift | 30 ++++++++ .../Native/RemoteHLSFormatDetection.swift | 8 +-- Sources/AetherEngine/PlayerState.swift | 10 ++- .../DisplayCriteriaLoadActionTests.swift | 8 +-- .../DisplayCriteriaPlayGateTests.swift | 16 ++--- .../Issue842DisplayCriteriaOwnerTests.swift | 69 +++++++++++++++++++ .../RemoteHLSFormatDetectionTests.swift | 6 +- docs/api.md | 3 +- 13 files changed, 157 insertions(+), 38 deletions(-) create mode 100644 Sources/AetherEngine/Display/DisplayCriteriaOwner.swift create mode 100644 Tests/AetherEngineTests/Issue842DisplayCriteriaOwnerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c710533..79d2cceae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,14 @@ the public-API contract. ## [Unreleased] -_Nothing yet._ +### Added + +- **`LoadOptions.displayCriteriaOwner` (`.engine` / `.avKit` / `.host`), AE#842.** `suppressDisplayCriteria` + means "AVKit writes the criteria", and every branch behind it waits for that write: a host that keeps + the panel in its system format (or writes rate-only criteria itself) had no correct setting and paid up + to a second of play-gate wait per HDR or Dolby Vision title for a write that never came, with the + engine's panel readout switched off. `.host` writes nothing, takes the brief budget, reads the panel + and still waits out a switch it observes. `suppressDisplayCriteria = true` keeps meaning `.avKit`. ## [7.40.1] - 2026-10-18 diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 660e7620b..3feadf5d4 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -719,12 +719,12 @@ extension AetherEngine { /// bypass. The loopback path applies criteria before item load from its libav probe; this path has no /// probe, so it applies late, once AVPlayer's parsed video-track format resolves the range. Without the /// panel switch a bare AVPlayerLayer presents no HDR video (audio-only, black; the reporter's symptom). - /// SDR needs no switch, and a sole-writer host (`suppressDisplayCriteria`) is left untouched. No-op off + /// SDR needs no switch, and criteria owned by AVKit or the host are left untouched (AE#842). No-op off /// tvOS (apply() returns .applied there) and where Match Content is off (apply() skips the write). @MainActor private func applyRemoteHLSDisplayCriteria(format: VideoFormat, options: LoadOptions) { guard RemoteHLSFormatDetection.shouldApplyDisplayCriteria( - format: format, suppressDisplayCriteria: options.suppressDisplayCriteria) else { return } + format: format, engineWritesCriteria: options.engineWritesDisplayCriteria) else { return } _ = displayCriteria.apply( format: format, frameRate: nativeHost?.detectedVideoFrameRate, @@ -2617,7 +2617,7 @@ extension AetherEngine { // #339: the only write this reload can still produce is a sole-writer host's re-write on the // swapped item, which happens inside loadNative. Arm before it, or the gate below is again // reading a flag for a switch whose notifications it was not registered for. - if loadedOptions.suppressDisplayCriteria { displayCriteria.armSwitchObservation() } + if !loadedOptions.engineWritesDisplayCriteria { displayCriteria.armSwitchObservation() } let reloadRoutingPanelHDR = Self.reloadRoutesAsHDRPanel( hostAsserts: loadedOptions.panelIsInHDRMode, criteriaReadoutAtLoad: sessionPanelHDRReadout, @@ -2693,7 +2693,7 @@ extension AetherEngine { await displayCriteria.waitForSwitch( startGrace: Self.playGateGrace( criteriaUnchanged: false, - engineIsCriteriaWriter: !loadedOptions.suppressDisplayCriteria, + criteriaWrittenBeforeLoad: !loadedOptions.awaitsInboundCriteriaWrite, formatKnown: true, effectiveFormat: videoFormat, noWriterExpected: loadedOptions.sharedOutputRole == .secondary diff --git a/Sources/AetherEngine/AetherEngine+Probe.swift b/Sources/AetherEngine/AetherEngine+Probe.swift index ee9c5df16..7e0c6e516 100644 --- a/Sources/AetherEngine/AetherEngine+Probe.swift +++ b/Sources/AetherEngine/AetherEngine+Probe.swift @@ -545,15 +545,15 @@ extension AetherEngine { enum LoadDisplayCriteriaAction { /// Engine-writer video path: apply() overwrites the preserved criteria in place, single handshake. case applyFresh - /// No engine apply() this session (audio-only, or an AVKit-sole-writer host): clear a criteria a + /// No engine apply() this session (audio-only, or criteria owned by AVKit or the host): clear a criteria a /// previous session left applied, so it can't keep the panel in DV/HDR under music playback or /// fight AVKit's own write. `DisplayCriteriaController.reset()` stays didApply-gated, so this is /// a no-op for hosts that never route criteria through the engine. case clearStale } - nonisolated static func loadDisplayCriteriaAction(suppressDisplayCriteria: Bool, audioOnlyPath: Bool) -> LoadDisplayCriteriaAction { - (suppressDisplayCriteria || audioOnlyPath) ? .clearStale : .applyFresh + nonisolated static func loadDisplayCriteriaAction(engineWritesCriteria: Bool, audioOnlyPath: Bool) -> LoadDisplayCriteriaAction { + (!engineWritesCriteria || audioOnlyPath) ? .clearStale : .applyFresh } /// #274: how long the post-load play-gate blind-polls for a panel switch to start. @@ -571,6 +571,9 @@ extension AetherEngine { /// rate-only, and the engine already declines to gate its own rate-only writes (`apply()` returns /// `.applied` with no pre-flight wait) because those switches are sub-second. /// + /// AE#842: a host-owned session counts as written-before-load too. The host wrote its criteria (or none) + /// before calling `load()`, so like the engine's own write there is nothing inbound to blind-poll for. + /// /// `formatKnown` is false when the open-time probe failed: the real range is then unknown and a DV write /// may still be inbound, so a suppressed host keeps the full budget. /// @@ -578,14 +581,14 @@ extension AetherEngine { /// no inbound switch to wait for. nonisolated static func playGateGrace( criteriaUnchanged: Bool, - engineIsCriteriaWriter: Bool, + criteriaWrittenBeforeLoad: Bool, formatKnown: Bool, effectiveFormat: VideoFormat, noWriterExpected: Bool = false ) -> DisplayCriteriaController.StartGrace { // #133: the criteria were already active, nothing was written, nothing can settle. if criteriaUnchanged || noWriterExpected { return .skip } - if engineIsCriteriaWriter { return .brief } + if criteriaWrittenBeforeLoad { return .brief } guard formatKnown else { return .full } return effectiveFormat == .sdr ? .brief : .full } @@ -595,6 +598,7 @@ extension AetherEngine { guard options.sharedOutputRole == .secondary else { return options } var adjusted = options adjusted.suppressDisplayCriteria = true + adjusted.displayCriteriaOwner = .engine return adjusted } diff --git a/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift b/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift index aaa5ba4e5..eb10cda34 100644 --- a/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift +++ b/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift @@ -443,7 +443,7 @@ enum SessionOptionCorrection { /// right for a tuning lever and wrong for an identity one, so the choice has to be made /// deliberately. Update this list and, if the field names the session, `loadIdentityFields`. static let knownFields: [String] = [ - "omitCriteriaColorExtensions", "suppressDisplayCriteria", "httpHeaders", "nativeVideoFrameDiagnostics", "nativeVideoFrameRecovery", "hostRetriesExhaustedLiveReplay", + "omitCriteriaColorExtensions", "suppressDisplayCriteria", "displayCriteriaOwner", "httpHeaders", "nativeVideoFrameDiagnostics", "nativeVideoFrameRecovery", "hostRetriesExhaustedLiveReplay", "keepDvh1TagWithoutDV", "forceDolbyVisionOnNonDVDisplay", "dolbyVisionHandling", "preferDualLayerDolbyVision", "matchContentEnabled", "panelIsInHDRMode", "attemptsHDRMasterOnUnprovenPanel", "panelPresentsDolbyVision", "audioBridgeMode", "objectAudioRendering", "isLive", "audioOnly", diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index bdbf1a191..74b2a9e11 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -4797,7 +4797,7 @@ public final class AetherEngine: ObservableObject { // The load seam preserves display criteria (#128 follow-up); an audio-only session never calls // apply(), so clear a criteria the previous video session left applied. The engine is a // process-wide singleton; without this, music playback keeps the panel in DV/HDR. - if Self.loadDisplayCriteriaAction(suppressDisplayCriteria: options.suppressDisplayCriteria, audioOnlyPath: true) == .clearStale { + if Self.loadDisplayCriteriaAction(engineWritesCriteria: options.engineWritesDisplayCriteria, audioOnlyPath: true) == .clearStale { resetDisplayCriteriaRespectingOthers() } // Read codec before closing the probe; custom sources always use FFmpeg (AVPlayer can't consume a custom demuxer). @@ -4890,7 +4890,7 @@ public final class AetherEngine: ObservableObject { // starts a switch, so the post-load play-gate waitForSwitch() below has nothing to settle and would // otherwise burn its full ~3s cap on unobservable-DV panels. Skip it in exactly that case. var criteriaUnchanged = false - switch Self.loadDisplayCriteriaAction(suppressDisplayCriteria: options.suppressDisplayCriteria, audioOnlyPath: false) { + switch Self.loadDisplayCriteriaAction(engineWritesCriteria: options.engineWritesDisplayCriteria, audioOnlyPath: false) { case .applyFresh: let codecTag: FourCharCode? = detectedDVProfile ? 0x64766831 : nil switch displayCriteria.apply( @@ -4927,7 +4927,7 @@ public final class AetherEngine: ObservableObject { // load rather than when the play gate opens. After reset(), so a switch back to the default // mode is not recorded as this session's. Audio-only loads reach clearStale too and have no // panel handshake to observe. - if options.suppressDisplayCriteria { displayCriteria.armSwitchObservation() } + if !options.engineWritesDisplayCriteria { displayCriteria.armSwitchObservation() } } // 2.5. Post-handshake panel-mode snapshot. @@ -4947,7 +4947,7 @@ public final class AetherEngine: ObservableObject { // so a panel parked in HDR never proves itself and one tvOS 27 box stopped answering at all; // a host that knows better says so, and a wrong claim costs the -11848 fallback, not the item. var criteriaPanelReadout: Bool? = - options.suppressDisplayCriteria ? nil : displayCriteria.currentPanelIsHDR() + options.awaitsInboundCriteriaWrite ? nil : displayCriteria.currentPanelIsHDR() var panelHDRAfterHandshake = Self.sessionPanelPresentsHDR( hostAsserts: options.panelIsInHDRMode, criteriaReadout: criteriaPanelReadout) // AE#459: the ROUTE may assume more than the LABEL may claim. `panelHDRAfterHandshake` stays the @@ -4978,7 +4978,7 @@ public final class AetherEngine: ObservableObject { } try checkLoadCurrent(gen) } - criteriaPanelReadout = options.suppressDisplayCriteria ? nil : displayCriteria.currentPanelIsHDR() + criteriaPanelReadout = options.awaitsInboundCriteriaWrite ? nil : displayCriteria.currentPanelIsHDR() panelHDRAfterHandshake = Self.sessionPanelPresentsHDR( hostAsserts: options.panelIsInHDRMode, criteriaReadout: criteriaPanelReadout) routingPanelHDR = Self.sessionRoutesAsHDRPanel( @@ -5495,7 +5495,7 @@ public final class AetherEngine: ObservableObject { await displayCriteria.waitForSwitch( startGrace: Self.playGateGrace( criteriaUnchanged: criteriaUnchanged, - engineIsCriteriaWriter: !options.suppressDisplayCriteria, + criteriaWrittenBeforeLoad: !options.awaitsInboundCriteriaWrite, formatKnown: probeOpened, effectiveFormat: effectiveFormat, noWriterExpected: options.sharedOutputRole == .secondary diff --git a/Sources/AetherEngine/Display/DisplayCriteriaOwner.swift b/Sources/AetherEngine/Display/DisplayCriteriaOwner.swift new file mode 100644 index 000000000..c465a3446 --- /dev/null +++ b/Sources/AetherEngine/Display/DisplayCriteriaOwner.swift @@ -0,0 +1,30 @@ +/// Who writes `AVDisplayManager.preferredDisplayCriteria` for a load (AE#842). See `LoadOptions.displayCriteriaOwner`. +public enum DisplayCriteriaOwner: String, Sendable, Equatable { + /// The engine writes range and rate before the item loads and waits for the switch. The default. + case engine + /// AVKit writes them from the `AVPlayerItem` (`AVPlayerViewController.appliesPreferredDisplayCriteriaAutomatically`), + /// somewhere inside the load. The engine holds play() up to a second for that write on HDR and Dolby Vision + /// content, and does not read the panel, because a readout before AVKit's write would be stale. + /// `suppressDisplayCriteria = true` is the older spelling of this case. + case avKit + /// The host decides before `load()`: it writes criteria of its own (rate-only, say), or none, so the panel + /// stays in the output format the user chose in tvOS Settings. The engine never writes, waits for no + /// write it was not told about, reads the panel as on its own path, and still waits out a switch it sees + /// running when the play gate opens. + case host +} + +extension LoadOptions { + /// The owner this load runs under. An explicit `displayCriteriaOwner` other than `.engine` wins; + /// otherwise `suppressDisplayCriteria` keeps its historical meaning, AVKit. + var effectiveDisplayCriteriaOwner: DisplayCriteriaOwner { + if displayCriteriaOwner != .engine { return displayCriteriaOwner } + return suppressDisplayCriteria ? .avKit : .engine + } + + var engineWritesDisplayCriteria: Bool { effectiveDisplayCriteriaOwner == .engine } + + /// Whether a criteria write may still land inside the load, from a writer whose timing the engine does + /// not control. Only AVKit's does; the engine's and the host's happened before the item was built. + var awaitsInboundCriteriaWrite: Bool { effectiveDisplayCriteriaOwner == .avKit } +} diff --git a/Sources/AetherEngine/Native/RemoteHLSFormatDetection.swift b/Sources/AetherEngine/Native/RemoteHLSFormatDetection.swift index ad21fadbb..7c5092599 100644 --- a/Sources/AetherEngine/Native/RemoteHLSFormatDetection.swift +++ b/Sources/AetherEngine/Native/RemoteHLSFormatDetection.swift @@ -34,10 +34,10 @@ enum RemoteHLSFormatDetection { /// Whether the nativeRemoteHLS bypass should program `preferredDisplayCriteria` for a detected format. /// Only an HDR range needs the panel switch that lets AVPlayer present HDR at all; SDR is presented in - /// any panel mode, and a sole-writer host (`suppressDisplayCriteria`) is left untouched so the engine - /// and the host never fight over the criteria. Pure so it is unit-testable. - static func shouldApplyDisplayCriteria(format: VideoFormat, suppressDisplayCriteria: Bool) -> Bool { - format != .sdr && !suppressDisplayCriteria + /// any panel mode, and criteria owned by AVKit or the host are left untouched so the engine and the + /// other writer never fight over them. Pure so it is unit-testable. + static func shouldApplyDisplayCriteria(format: VideoFormat, engineWritesCriteria: Bool) -> Bool { + format != .sdr && engineWritesCriteria } /// Video sample types the bypass can name, mapped to the libavcodec spelling the probe path publishes. diff --git a/Sources/AetherEngine/PlayerState.swift b/Sources/AetherEngine/PlayerState.swift index d2e18e7df..191c821d2 100644 --- a/Sources/AetherEngine/PlayerState.swift +++ b/Sources/AetherEngine/PlayerState.swift @@ -414,8 +414,14 @@ public enum DolbyVisionHandling: String, Sendable, Equatable, CaseIterable { public struct LoadOptions: Sendable, Equatable { /// Diagnostic lever: omit BT.2020 / transfer / YCbCr matrix from AVDisplayCriteria so AVPlayer re-reads color from the bitstream. Default off. public var omitCriteriaColorExtensions: Bool - /// Skip display-criteria handshake entirely. For previews and `aetherctl` where no panel exists. Default off. + /// The engine writes no display criteria because AVKit does: `true` is `DisplayCriteriaOwner.avKit`, and the + /// engine holds play() up to a second for AVKit's write on HDR and Dolby Vision content. Also the setting for + /// previews and `aetherctl`, where no panel exists. A host that keeps the panel in its system format, or + /// writes criteria itself, wants `displayCriteriaOwner = .host` instead. Default off. public var suppressDisplayCriteria: Bool + /// Who writes `preferredDisplayCriteria` for this load (AE#842). `.engine` (default) defers to + /// `suppressDisplayCriteria`; `.avKit` and `.host` win over it. See `DisplayCriteriaOwner`. + public var displayCriteriaOwner: DisplayCriteriaOwner = .engine /// Extra HTTP headers for HEAD probe, Range chunks, side-demuxer fetches. On the loopback paths they are NOT forwarded to AVPlayer (it hits the local server); on `nativeRemoteHLS` they ride into the AVURLAsset so header-enforcing origins (IPTV Referer / User-Agent / Authorization) work (#119). Forwarded to `selectSidecarSubtitle` by default; pass explicit headers to override (#32). Default empty. public var httpHeaders: [String: String] @@ -984,6 +990,7 @@ public struct LoadOptions: Sendable, Equatable { public init( omitCriteriaColorExtensions: Bool = false, suppressDisplayCriteria: Bool = false, + displayCriteriaOwner: DisplayCriteriaOwner = .engine, httpHeaders: [String: String] = [:], keepDvh1TagWithoutDV: Bool = false, forceDolbyVisionOnNonDVDisplay: Bool = false, @@ -1038,6 +1045,7 @@ public struct LoadOptions: Sendable, Equatable { ) { self.omitCriteriaColorExtensions = omitCriteriaColorExtensions self.suppressDisplayCriteria = suppressDisplayCriteria + self.displayCriteriaOwner = displayCriteriaOwner self.httpHeaders = httpHeaders self.keepDvh1TagWithoutDV = keepDvh1TagWithoutDV self.forceDolbyVisionOnNonDVDisplay = forceDolbyVisionOnNonDVDisplay diff --git a/Tests/AetherEngineTests/DisplayCriteriaLoadActionTests.swift b/Tests/AetherEngineTests/DisplayCriteriaLoadActionTests.swift index 6865e1bd0..748e3cd07 100644 --- a/Tests/AetherEngineTests/DisplayCriteriaLoadActionTests.swift +++ b/Tests/AetherEngineTests/DisplayCriteriaLoadActionTests.swift @@ -6,23 +6,23 @@ struct DisplayCriteriaLoadActionTests { @Test("Video load with the engine as criteria writer applies fresh criteria in place") func videoEngineWriterAppliesFresh() { - #expect(AetherEngine.loadDisplayCriteriaAction(suppressDisplayCriteria: false, audioOnlyPath: false) == .applyFresh) + #expect(AetherEngine.loadDisplayCriteriaAction(engineWritesCriteria: true, audioOnlyPath: false) == .applyFresh) } @Test("Suppressed (AVKit-sole-writer) load clears a stale engine criteria instead of applying") func suppressedClearsStale() { // A previous non-suppressed session may have left its criteria applied across the preserved // load seam; leaving it in place alongside AVKit's own write recreates the dual-writer fight. - #expect(AetherEngine.loadDisplayCriteriaAction(suppressDisplayCriteria: true, audioOnlyPath: false) == .clearStale) + #expect(AetherEngine.loadDisplayCriteriaAction(engineWritesCriteria: false, audioOnlyPath: false) == .clearStale) } @Test("Audio-only load clears a stale criteria so music cannot inherit the video session's panel mode") func audioOnlyClearsStale() { - #expect(AetherEngine.loadDisplayCriteriaAction(suppressDisplayCriteria: false, audioOnlyPath: true) == .clearStale) + #expect(AetherEngine.loadDisplayCriteriaAction(engineWritesCriteria: true, audioOnlyPath: true) == .clearStale) } @Test("Suppressed audio-only load also clears") func suppressedAudioOnlyClearsStale() { - #expect(AetherEngine.loadDisplayCriteriaAction(suppressDisplayCriteria: true, audioOnlyPath: true) == .clearStale) + #expect(AetherEngine.loadDisplayCriteriaAction(engineWritesCriteria: false, audioOnlyPath: true) == .clearStale) } } diff --git a/Tests/AetherEngineTests/DisplayCriteriaPlayGateTests.swift b/Tests/AetherEngineTests/DisplayCriteriaPlayGateTests.swift index bfc6c12f5..3b7eadcce 100644 --- a/Tests/AetherEngineTests/DisplayCriteriaPlayGateTests.swift +++ b/Tests/AetherEngineTests/DisplayCriteriaPlayGateTests.swift @@ -18,10 +18,10 @@ struct DisplayCriteriaPlayGateTests { @Test("Unchanged criteria skip the gate entirely (#133 behaviour preserved)") func unchangedCriteriaSkip() { #expect(AetherEngine.playGateGrace( - criteriaUnchanged: true, engineIsCriteriaWriter: true, + criteriaUnchanged: true, criteriaWrittenBeforeLoad: true, formatKnown: true, effectiveFormat: .dolbyVision) == .skip) #expect(AetherEngine.playGateGrace( - criteriaUnchanged: true, engineIsCriteriaWriter: false, + criteriaUnchanged: true, criteriaWrittenBeforeLoad: false, formatKnown: false, effectiveFormat: .sdr) == .skip) } @@ -29,7 +29,7 @@ struct DisplayCriteriaPlayGateTests { func engineWriterIsBrief() { for format: VideoFormat in [.sdr, .hdr10, .hdr10Plus, .hlg, .dolbyVision] { #expect(AetherEngine.playGateGrace( - criteriaUnchanged: false, engineIsCriteriaWriter: true, + criteriaUnchanged: false, criteriaWrittenBeforeLoad: true, formatKnown: true, effectiveFormat: format) == .brief) } } @@ -38,7 +38,7 @@ struct DisplayCriteriaPlayGateTests { func suppressedHostHDRIsFull() { for format: VideoFormat in [.hdr10, .hdr10Plus, .hlg, .dolbyVision] { #expect(AetherEngine.playGateGrace( - criteriaUnchanged: false, engineIsCriteriaWriter: false, + criteriaUnchanged: false, criteriaWrittenBeforeLoad: false, formatKnown: true, effectiveFormat: format) == .full) } } @@ -46,7 +46,7 @@ struct DisplayCriteriaPlayGateTests { @Test("Sole-writer host with SDR content takes the brief budget: only a rate-only write can still arrive") func suppressedHostSDRIsBrief() { #expect(AetherEngine.playGateGrace( - criteriaUnchanged: false, engineIsCriteriaWriter: false, + criteriaUnchanged: false, criteriaWrittenBeforeLoad: false, formatKnown: true, effectiveFormat: .sdr) == .brief) } @@ -54,7 +54,7 @@ struct DisplayCriteriaPlayGateTests { func suppressedHostUnknownFormatIsFull() { // effectiveFormat defaults to .sdr on a failed probe, so the format alone must not decide here. #expect(AetherEngine.playGateGrace( - criteriaUnchanged: false, engineIsCriteriaWriter: false, + criteriaUnchanged: false, criteriaWrittenBeforeLoad: false, formatKnown: false, effectiveFormat: .sdr) == .full) } @@ -63,7 +63,7 @@ struct DisplayCriteriaPlayGateTests { for format: VideoFormat in [.sdr, .hdr10, .dolbyVision] { for known in [true, false] { #expect(AetherEngine.playGateGrace( - criteriaUnchanged: false, engineIsCriteriaWriter: false, + criteriaUnchanged: false, criteriaWrittenBeforeLoad: false, formatKnown: known, effectiveFormat: format, noWriterExpected: true) == .skip) } } @@ -97,7 +97,7 @@ struct DisplayCriteriaPlayGateTests { func soleWriterHDRStillWaitsWhenMatchingIsOn() { // playGateGrace hands this path .full; with matching on it must still be waited out (#274). let grace = AetherEngine.playGateGrace( - criteriaUnchanged: false, engineIsCriteriaWriter: false, + criteriaUnchanged: false, criteriaWrittenBeforeLoad: false, formatKnown: true, effectiveFormat: .dolbyVision) #expect(grace == .full) #expect(DisplayCriteriaController.shouldWait(startGrace: grace, matchingEnabled: true)) diff --git a/Tests/AetherEngineTests/Issue842DisplayCriteriaOwnerTests.swift b/Tests/AetherEngineTests/Issue842DisplayCriteriaOwnerTests.swift new file mode 100644 index 000000000..dc3f338d2 --- /dev/null +++ b/Tests/AetherEngineTests/Issue842DisplayCriteriaOwnerTests.swift @@ -0,0 +1,69 @@ +import Testing +@testable import AetherEngine + +/// AE#842: `suppressDisplayCriteria` means "AVKit writes", so a host that keeps the panel in its system +/// format (or writes criteria itself) had no correct setting and paid for an AVKit write that never came. +@Suite("Display-criteria owner: resolution and what each owner costs") +struct Issue842DisplayCriteriaOwnerTests { + + private func options(owner: DisplayCriteriaOwner = .engine, suppress: Bool = false) -> LoadOptions { + var options = LoadOptions() + options.displayCriteriaOwner = owner + options.suppressDisplayCriteria = suppress + return options + } + + @Test("the default is the engine, and suppressDisplayCriteria keeps meaning AVKit") + func defaultsAndLegacySpelling() { + #expect(options().effectiveDisplayCriteriaOwner == .engine) + #expect(options(suppress: true).effectiveDisplayCriteriaOwner == .avKit) + } + + @Test("an explicit owner wins over suppressDisplayCriteria") + func explicitOwnerWins() { + #expect(options(owner: .host, suppress: true).effectiveDisplayCriteriaOwner == .host) + #expect(options(owner: .host, suppress: false).effectiveDisplayCriteriaOwner == .host) + #expect(options(owner: .avKit, suppress: false).effectiveDisplayCriteriaOwner == .avKit) + } + + @Test("only the engine owner writes, so a host-owned load clears the engine's leftover and applies nothing") + func hostNeverWrites() { + let host = options(owner: .host) + #expect(!host.engineWritesDisplayCriteria) + #expect(AetherEngine.loadDisplayCriteriaAction( + engineWritesCriteria: host.engineWritesDisplayCriteria, audioOnlyPath: false) == .clearStale) + #expect(!RemoteHLSFormatDetection.shouldApplyDisplayCriteria( + format: .dolbyVision, engineWritesCriteria: host.engineWritesDisplayCriteria)) + } + + @Test("only AVKit's write can land inside the load") + func onlyAVKitIsInbound() { + #expect(options(owner: .avKit).awaitsInboundCriteriaWrite) + #expect(options(suppress: true).awaitsInboundCriteriaWrite) + #expect(!options(owner: .host).awaitsInboundCriteriaWrite) + #expect(!options().awaitsInboundCriteriaWrite) + } + + @Test("a host-owned HDR load takes the brief play-gate budget, not AVKit's second") + func hostOwnedHDRIsBrief() { + for format: VideoFormat in [.hdr10, .hdr10Plus, .hlg, .dolbyVision] { + #expect(AetherEngine.playGateGrace( + criteriaUnchanged: false, + criteriaWrittenBeforeLoad: !options(owner: .host).awaitsInboundCriteriaWrite, + formatKnown: true, effectiveFormat: format) == .brief) + #expect(AetherEngine.playGateGrace( + criteriaUnchanged: false, + criteriaWrittenBeforeLoad: !options(owner: .avKit).awaitsInboundCriteriaWrite, + formatKnown: true, effectiveFormat: format) == .full) + } + } + + @Test("a secondary engine stays a non-writer with no panel readout, whatever owner the host passed") + func secondaryStaysSuppressed() { + var proposed = options(owner: .host) + proposed.sharedOutputRole = .secondary + let adjusted = AetherEngine.applyingSharedOutputRole(proposed) + #expect(adjusted.effectiveDisplayCriteriaOwner == .avKit) + #expect(!adjusted.engineWritesDisplayCriteria) + } +} diff --git a/Tests/AetherEngineTests/RemoteHLSFormatDetectionTests.swift b/Tests/AetherEngineTests/RemoteHLSFormatDetectionTests.swift index 84379c679..c9585c3ee 100644 --- a/Tests/AetherEngineTests/RemoteHLSFormatDetectionTests.swift +++ b/Tests/AetherEngineTests/RemoteHLSFormatDetectionTests.swift @@ -88,19 +88,19 @@ struct RemoteHLSFormatDetectionTests { ]) func hdrAppliesCriteria(_ format: VideoFormat) { #expect(RemoteHLSFormatDetection.shouldApplyDisplayCriteria( - format: format, suppressDisplayCriteria: false)) + format: format, engineWritesCriteria: true)) } @Test("SDR never warrants a panel switch") func sdrSkipsCriteria() { #expect(!RemoteHLSFormatDetection.shouldApplyDisplayCriteria( - format: .sdr, suppressDisplayCriteria: false)) + format: .sdr, engineWritesCriteria: true)) } @Test("A sole-writer host suppresses the switch even for HDR") func suppressedSkipsCriteria() { #expect(!RemoteHLSFormatDetection.shouldApplyDisplayCriteria( - format: .hdr10, suppressDisplayCriteria: true)) + format: .hdr10, engineWritesCriteria: false)) } // MARK: - codecName diff --git a/docs/api.md b/docs/api.md index 6c9cd68e0..a9aa3e433 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1311,7 +1311,8 @@ All flags default to safe values; the table is the full set. Depth for the media | `preserveASSMarkup` | false | Emit raw ASS event lines instead of extracted text; pair with `TrackInfo.assHeader`. ASS / SSA codecs only, embedded and sidecar alike, so a session mixing an ASS track with a SubRip one needs no reload to cross between them (AE#587). | | `teletextPage` | nil | Fix the DVB teletext caption page instead of letting libzvbi auto-detect. | | `audioDelaySeconds` | 0 | Start the session with a lip-sync offset already in force; positive presents audio later. Same value `setAudioDelay(_:)` reads and writes, and a `reloadAtCurrentPosition(applying:)` can correct it. | -| `suppressDisplayCriteria` | false | Skip the display-criteria handshake entirely. For previews and headless runs. A host that clears `preferredDisplayCriteria` itself between loads (leaving full screen for a preview) no longer strands the engine's same-format skip: the next non-suppressed load reads the display manager, finds nothing set and writes again (AE#678). Clearing through `stop()` stays the cleaner path, because it resets the engine's record in the same step. | +| `suppressDisplayCriteria` | false | The engine writes no display criteria because AVKit does (`AVPlayerViewController.appliesPreferredDisplayCriteriaAutomatically`): the older spelling of `displayCriteriaOwner = .avKit`, and the setting for previews and headless runs. The play gate holds up to a second for AVKit's write on HDR and Dolby Vision content and the panel readout is skipped, so a host that keeps the panel in its system format wants `.host` instead (AE#842). A host that clears `preferredDisplayCriteria` itself between loads (leaving full screen for a preview) no longer strands the engine's same-format skip: the next non-suppressed load reads the display manager, finds nothing set and writes again (AE#678). Clearing through `stop()` stays the cleaner path, because it resets the engine's record in the same step. | +| `displayCriteriaOwner` | `.engine` | A `DisplayCriteriaOwner`: who writes `preferredDisplayCriteria` (AE#842). `.engine` writes range and rate before the item loads and defers to `suppressDisplayCriteria`. `.avKit` is AVKit's write from the item. `.host` is a host that writes its own criteria before `load()`, or none, so the panel stays in the output format chosen in tvOS Settings (a Dynamic Range "System" setting): the engine never writes, clears only a criteria it left applied itself, takes the brief play-gate budget instead of AVKit's second, reads the panel as on its own path and still waits out a switch it sees running. `.avKit` and `.host` win over `suppressDisplayCriteria`. A `.secondary` engine is always a non-writer. | | `matchContentEnabled` | true | Mirror of `AVDisplayManager.isDisplayCriteriaMatchingEnabled`. False routes HDR through the auto-tonemap path. | | `panelIsInHDRMode` | false | **Host assertion** that the panel is presenting HDR right now, and the gate on whether the HDR10-to-DV upgrade is accepted upfront. It is an OR term over the engine's own readout on every platform, not a replacement for it and no longer confined to `suppressDisplayCriteria` hosts (AE#459). The readout it backs up is `currentEDRHeadroom > 1`, which answers only around a dynamic-range transition: an Apple TV whose output format is locked to HDR never makes one and reads as an SDR panel forever, and on tvOS 27 the property has stopped answering at all on at least one box. A wrong assertion costs one in-place media-playlist fallback (`-11848`), not the item. An in-place rebuild (an audio pick, a custom-source reload) runs no handshake, so it routes on the load's readout and display eligibility together with the session's current assertion and the refusal latch, rather than on this option alone (AE#541). | | `attemptsHDRMasterOnUnprovenPanel` | true | Serve the HDR master to an HDR-eligible display whose panel state is unproven, and let AVFoundation's acceptance or refusal be the readout `UIScreen` will not give (AE#459). VOD only. The proxy this replaces is `currentEDRHeadroom`, which is measurably unreliable: on one Apple TV 4K 3rd gen on tvOS 26.6 it read a flat 1.00 across 46 samples of HDR content while the TV's own info display reported HDR, and later the same day, same box, same output format, same title, it read 1.20. The panel was presenting HDR and the property was wrong about it; what moves it is not established. What media-direct costs there is not the picture but the manifest: the SUBTITLES rendition, the AUDIO rendition that is the only place AVFoundation reads an HLS language from, and SUPPLEMENTAL-CODECS. A panel that proves itself through the headroom short-circuits and attempts nothing. A refusal costs one in-place media fallback, measured at 223 ms end to end (`-11868` after 54 ms, zero `errorLog` events, position kept, no visible black frame), and is latched for the process, so a genuinely SDR display pays it once rather than per title. Set it false on a host that knows its display is SDR. Live never attempts regardless: a live fallback is a rejoin at the edge rather than a restored position, and that cost is unmeasured. The published `videoFormat` is not moved by an ATTEMPT. It is moved by an ACCEPTANCE: a master AVFoundation has not refused after the settle window publishes the presented format, which is a stronger reading than the headroom rather than a substitute for it. |