diff --git a/CHANGELOG.md b/CHANGELOG.md index d30c69c3a..45f8adf7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,24 @@ the public-API contract. ## [Unreleased] -_Nothing yet._ +### Added + +- **`LoadOptions.foldsDolbyVisionFEL` turns the FEL curve fold off per load (#1018).** Default `true`, so + nothing changes unless a host asks. Off, a Profile 7 FEL source gets the plain Profile 8.1 conversion: no GOP + held back, no decode per scene, the start and seek speed of a build without the `DolbyVisionFelCurves` trait. + A tuning field that `reloadAtCurrentPosition(applying:)` carries; ignored without the trait. The load logs + `[FelCurves] fold not armed: ` when a conversion runs without it. + +### Changed + +- **The FEL curve fold pauses where its RPU cannot reach the picture (#1018).** Measured on an Apple TV 4K at an + HDR10 television: with the panel in HDR10, tvOS shows the base layer bit for bit and ignores the RPU, while a + panel in SDR, the `forceDolbyVisionOnNonDVDisplay` route and a Mac all apply it. On tvOS without Dolby Vision, + off the Profile 5 route, while the panel presents HDR (predicted at load from range matching and the HDR + criteria write, confirmed by the accepted master, cleared by a refused one), the fold now passes packets + through, without holding a GOP back or decoding anything. The reporter measured a resume 0.5 to 4 s slower + with the fold on such a display; from a network source the first load now matches the fold switched off. + Logged as `[FelCurves] fold paused`. ## [7.62.1] - 2026-11-10 diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index a529d2906..49f453b3d 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -224,6 +224,13 @@ extension AetherEngine { // `liveItemPlacementPending`. if ready { self.noteLiveItemStart() + // AE#1018: an HDR master reaching readyToPlay was accepted, and a display presenting SDR + // refuses one at parse time (AE#459), so the panel presents HDR. The load-time readout + // is taken mid-switch and reads 1.00 on exactly the panels this has to recognise. + if let session = self.nativeVideoSession, session.servingMasterPlaylist, + session.servedSourceIsHDR, !self.masterFallbackUsed { + session.notePanelPresentsHDR(true) + } // The placement is spent here, on every path rather than only on the one that // replays the stashed seek: a pre-ready seek can be superseded by a host scrub // (latest-wins), and an arm left standing would be inherited by whatever item the @@ -845,7 +852,8 @@ extension AetherEngine { keepsMediaPlaylistRoute: Bool = false, autostarts: Bool, generation: UInt64, - resolvePanelRouting: (() async throws -> Bool)? = nil + resolvePanelRouting: (() async throws -> Bool)? = nil, + panelPresentsHDR: Bool = false ) async throws { // companionAudioReader is set by the reader's resolver before any main-stream byte flows, so it is // final by the time loadNative runs; nil means muxed audio. @@ -917,6 +925,8 @@ extension AetherEngine { undecodableAudioStreamIndices: undecodableLiveAudioStreamIndices, audioBridgeMode: audioBridgeMode, objectAudioRendering: loadedOptions.objectAudioRendering, + foldsDolbyVisionFEL: loadedOptions.foldsDolbyVisionFEL, + panelPresentsHDR: panelPresentsHDR, stereoPresentation: loadedOptions.stereoPresentation, isLiveSession: isLive, dvrWindowSeconds: dvrWindowSeconds, @@ -1350,6 +1360,10 @@ extension AetherEngine { let routesAsHDRPanel: Bool do { routesAsHDRPanel = try await resolvePanelRouting() + if Self.sessionPanelPresentsHDR(hostAsserts: loadedOptions.panelIsInHDRMode, + criteriaReadout: sessionPanelHDRReadout) { + session.notePanelPresentsHDR(true) + } } catch { session.stop(reason: .loadFailed) throw error @@ -2865,7 +2879,13 @@ extension AetherEngine { preopenedDemuxer: customPreopened, keepsMediaPlaylistRoute: keepsMediaRoute, autostarts: transportIntentUnderReconstruction ?? resumesPlaying, - generation: gen + generation: gen, + panelPresentsHDR: Self.felGatePanelPresentsHDR( + labelPresentsHDR: Self.sessionPanelPresentsHDR( + hostAsserts: loadedOptions.panelIsInHDRMode, criteriaReadout: sessionPanelHDRReadout), + hdrWriteEngagesPanel: displayCriteria.hdrWriteExpectedToEngagePanel(), + engineWritesHDRCriteria: loadedOptions.engineWritesDisplayCriteria && videoFormat != .sdr, + panelRefusedHDRMaster: Self.panelRefusedHDRMaster || masterFallbackUsed) ) EngineLog.emit("[AetherEngine] reload: loadNative done (\(elapsedMs(since: loadStart))ms)", category: .engine) playbackBackend = .native diff --git a/Sources/AetherEngine/AetherEngine+Probe.swift b/Sources/AetherEngine/AetherEngine+Probe.swift index 4dbcf7b81..5eb654c3d 100644 --- a/Sources/AetherEngine/AetherEngine+Probe.swift +++ b/Sources/AetherEngine/AetherEngine+Probe.swift @@ -1072,6 +1072,16 @@ extension AetherEngine { return .dolbyVision } + /// AE#1018: the panel answer the FEL fold gate starts a session with. The label alone arrives too late: + /// the first producer reads during the display switch, when the readout says 1.00 on the panels this + /// has to recognise. So an HDR criteria write with range matching on, on a display that has not refused + /// an HDR master in this process, counts as HDR from the start; a refusal clears it on the session. + nonisolated static func felGatePanelPresentsHDR(labelPresentsHDR: Bool, hdrWriteEngagesPanel: Bool, + engineWritesHDRCriteria: Bool, + panelRefusedHDRMaster: Bool) -> Bool { + labelPresentsHDR || (hdrWriteEngagesPanel && engineWritesHDRCriteria && !panelRefusedHDRMaster) + } + /// AE#459: what this session takes the panel to be presenting, from the host's assertion and the /// engine's own criteria readout. /// diff --git a/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift b/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift index 2f3714071..0e6ab16ed 100644 --- a/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift +++ b/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift @@ -485,7 +485,7 @@ enum SessionOptionCorrection { static let knownFields: [String] = [ "sourceDiagnosticsContext", "omitCriteriaColorExtensions", "suppressDisplayCriteria", "displayCriteriaOwner", "httpHeaders", "nativeVideoFrameDiagnostics", "nativeVideoFrameRecovery", "hostRetriesExhaustedLiveReplay", - "keepDvh1TagWithoutDV", "forceDolbyVisionOnNonDVDisplay", "dolbyVisionHandling", "preferDualLayerDolbyVision", "dualLayerRoute", "upscalesLowResolutionVideo", "matchContentEnabled", + "keepDvh1TagWithoutDV", "forceDolbyVisionOnNonDVDisplay", "dolbyVisionHandling", "preferDualLayerDolbyVision", "dualLayerRoute", "foldsDolbyVisionFEL", "upscalesLowResolutionVideo", "matchContentEnabled", "panelIsInHDRMode", "attemptsHDRMasterOnUnprovenPanel", "panelPresentsDolbyVision", "audioBridgeMode", "objectAudioRendering", "stereoPresentation", "isLive", "audioOnly", "dvrWindowSeconds", diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 4f33be9a9..d40717793 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -3226,6 +3226,7 @@ public final class AetherEngine: ObservableObject { return } masterFallbackUsed = true + session.notePanelPresentsHDR(false) // AE#1018: the fold resumes at the next keyframe masterRefusedByDisplay = MasterFallbackDecision.isDisplayRejectionCode(rejection.code) && session.servedSourceIsHDR // AE#459: the display answered. Display-rejection codes only, never the -1002 parse failure. @@ -3518,6 +3519,7 @@ public final class AetherEngine: ObservableObject { throw StartupGateFailure(message: startupGateFailureMessage(host)) } masterFallbackUsed = true + session.notePanelPresentsHDR(false) // AE#1018 session.markServingMediaAfterFallback() nativeSubtitleRenditionsServed = false EngineLog.emit( @@ -6308,7 +6310,12 @@ public final class AetherEngine: ObservableObject { resolvePanelRouting: panelRoutingSettled ? nil : { try await settlePanelRouting(ownsProbe: false) return routingPanelHDR - } + }, + panelPresentsHDR: Self.felGatePanelPresentsHDR( + labelPresentsHDR: panelRoutingSettled && panelHDRAfterHandshake, + hdrWriteEngagesPanel: displayCriteria.hdrWriteExpectedToEngagePanel(), + engineWritesHDRCriteria: options.engineWritesDisplayCriteria && effectiveFormat != .sdr, + panelRefusedHDRMaster: Self.panelRefusedHDRMaster) ) playbackBackend = .native // Preserve source evidence for a later native-to-software recovery. @@ -7789,6 +7796,7 @@ public final class AetherEngine: ObservableObject { /// published format and the served stream drift apart. @MainActor func republishPanelPresentsHDR(effectiveFormat: VideoFormat, because reason: String) { + nativeVideoSession?.notePanelPresentsHDR(true) // AE#1018: the FEL fold gate reads the same answer let corrected = Self.presentedVideoFormat( effectiveFormat: effectiveFormat, panelPresentsHDR: true, diff --git a/Sources/AetherEngine/Display/DisplayCriteriaController.swift b/Sources/AetherEngine/Display/DisplayCriteriaController.swift index 0cbf3a3c1..193d0a7f0 100644 --- a/Sources/AetherEngine/Display/DisplayCriteriaController.swift +++ b/Sources/AetherEngine/Display/DisplayCriteriaController.swift @@ -1366,6 +1366,18 @@ final class DisplayCriteriaController { #endif } + /// AE#1018: whether an HDR criteria write is expected to put the panel in HDR, before the switch has + /// answered. tvOS exposes one combined matching flag, so a rate-only setup reads true here too; the + /// display's refusal of the HDR master corrects that within the first second. + func hdrWriteExpectedToEngagePanel() -> Bool { + #if os(tvOS) + guard let window = resolveWindow() else { return false } + return window.avDisplayManager.isDisplayCriteriaMatchingEnabled && AVPlayer.eligibleForHDRPlayback + #else + return false + #endif + } + /// Whether the panel presents this session in HDR, read after apply() + waitForSwitch() settle. A live /// headroom above 1.0 answers it; a decayed one is answered by `panelPresentsHDR` from what this display /// has already proven, because HDR video on the screen pulls the value down and a replay asks while the diff --git a/Sources/AetherEngine/PlayerState.swift b/Sources/AetherEngine/PlayerState.swift index 962832d72..25ee15242 100644 --- a/Sources/AetherEngine/PlayerState.swift +++ b/Sources/AetherEngine/PlayerState.swift @@ -544,6 +544,24 @@ public struct LoadOptions: Sendable, Equatable { /// A tuning field, with the same fresh-load rule for a custom source as `preferDualLayerDolbyVision`. public var dualLayerRoute: DualLayerRoute = .softwareHost + /// Whether a Profile 7 FEL source on the native route gets its enhancement-layer luma residual folded + /// into the Profile 8.1 RPU's luma curve per scene (AE#988, the `DolbyVisionFelCurves` package trait). + /// Default true, which is the behaviour of every build with the trait. False keeps the plain mode 2 + /// conversion: no GOP held back between demuxer and segmenter, no base- and enhancement-layer decode + /// per scene, so a start or a seek is as fast as on a build without the trait (AE#1018 measured a + /// resume 0.5 to 4 s faster on an Apple TV 4K). Use it to turn the fold off for a title a viewer + /// reports, or to compare the two on one build. + /// + /// Independent of this field, the fold pauses on its own where its RPU cannot reach the picture: on tvOS, + /// at a display without Dolby Vision whose panel presents HDR10 (predicted at load, see AE#1018), off the + /// `forceDolbyVisionOnNonDVDisplay` route (measured: tvOS hands such a panel the base layer). A Mac, a + /// panel in SDR and the Profile 5 route all apply the RPU, so the fold stays on there. + /// + /// Without the `DolbyVisionFelCurves` trait this field is ignored, like `preferDualLayerDolbyVision` + /// without `DualLayerDolbyVision`. A tuning field: `reloadAtCurrentPosition(applying:)` carries a + /// change, and every producer the session builds afterwards (seek, revive) reads the new value. + public var foldsDolbyVisionFEL: Bool = true + /// Opt in to upscaling SDR video below 1080p (coded width under 1920 and height under 1080) to the /// display's native pixel size (#846). Such a source routes through `SoftwarePlaybackHost`, where every /// decoded frame runs VideoToolbox low-latency super resolution where the device and frame size allow @@ -1137,6 +1155,7 @@ public struct LoadOptions: Sendable, Equatable { dolbyVisionHandling: DolbyVisionHandling = .automatic, preferDualLayerDolbyVision: Bool = false, dualLayerRoute: DualLayerRoute = .softwareHost, + foldsDolbyVisionFEL: Bool = true, upscalesLowResolutionVideo: Bool = false, matchContentEnabled: Bool = true, panelIsInHDRMode: Bool = false, @@ -1202,6 +1221,7 @@ public struct LoadOptions: Sendable, Equatable { self.dolbyVisionHandling = dolbyVisionHandling self.preferDualLayerDolbyVision = preferDualLayerDolbyVision self.dualLayerRoute = dualLayerRoute + self.foldsDolbyVisionFEL = foldsDolbyVisionFEL self.upscalesLowResolutionVideo = upscalesLowResolutionVideo self.matchContentEnabled = matchContentEnabled self.panelIsInHDRMode = panelIsInHDRMode diff --git a/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift b/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift index b8211fba7..7be383722 100644 --- a/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift +++ b/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift @@ -57,7 +57,16 @@ final class FelCurveLookahead { private(set) var scenesFailed = 0 private(set) var conversionFailures = 0 - init(videoStreamIndex: Int32, stream: UnsafeMutablePointer, framing: VideoNALFraming) { + /// True while the folded curves cannot reach the picture (AE#1018: a tvOS panel presenting HDR10 without + /// Dolby Vision gets the base layer, the RPU is not applied). Read at every GOP boundary; while it holds, + /// packets pass through converted with the stream's mapping, with no GOP held back and nothing decoded. + private let foldIsUnseen: () -> Bool + /// Pass-through while `foldIsUnseen` held at the last GOP boundary; folding resumes at a keyframe. + private(set) var bypassing = false + + init(videoStreamIndex: Int32, stream: UnsafeMutablePointer, framing: VideoNALFraming, + foldIsUnseen: @escaping () -> Bool = { false }) { + self.foldIsUnseen = foldIsUnseen self.videoStreamIndex = videoStreamIndex self.stream = stream self.framing = framing @@ -98,8 +107,20 @@ final class FelCurveLookahead { if isFolding == false || (!isVideo && buffered.isEmpty) { return packet } - if isVideo, packet.pointee.flags & AV_PKT_FLAG_KEY != 0, bufferedHasVideo { - release() + if isVideo, packet.pointee.flags & AV_PKT_FLAG_KEY != 0 { + // The held GOP goes out first, then the gate is read: a keyframe is where the fold may leave + // or rejoin, in either direction, without splitting a GOP. + if bufferedHasVideo { release() } + updateBypass() + } + if bypassing { + convert(packet, luma: nil) + // A GOP released just above is still queued in `ready`; the keyframe must not overtake it. + if readyIndex < ready.count { + ready.append(packet) + continue + } + return packet } buffered.append(packet) if isVideo { bufferedHasVideo = true } @@ -107,6 +128,33 @@ final class FelCurveLookahead { } } + /// Re-reads `foldIsUnseen` where a GOP can start, so the fold leaves and rejoins at a keyframe. + private func updateBypass() { + let unseen = foldIsUnseen() + guard unseen != bypassing else { return } + bypassing = unseen + carried = nil + carriedValid = false + EngineLog.emit(unseen + ? "[FelCurves] fold paused: the panel presents HDR without Dolby Vision, so the RPU does not reach " + + "the picture; packets pass through with the stream's mapping, no GOP held back" + : "[FelCurves] fold resumed at a keyframe", category: .session) + } + + private func convert(_ packet: UnsafeMutablePointer, luma: FelCurve?) { + guard packet.pointee.stream_index == videoStreamIndex else { return } + // A refused RPU is dropped and the packet rebuilt without its enhancement layer, as on the plain + // route: the frame shows its HDR10 base. Logged once, since the producer skips its own conversion. + if !DoviRpuConverter.convertPacketToProfile81KeepingMapping( + packet, framing: framing, rewrite: luma.map { curve in { curve.apply(to: $0, component: 0) } }) { + conversionFailures += 1 + if conversionFailures == 1 { + EngineLog.emit("[FelCurves] DV P7->8.1 conversion failed for a packet; dropped the RPU, " + + "degrading affected frames to the HDR10 base", category: .session) + } + } + } + /// Drops everything held, for a demuxer that has just been repositioned. func reset() { for i in readyIndex..8.1 conversion failed for a packet; dropped the RPU, " - + "degrading affected frames to the HDR10 base", category: .session) - } - } + convert(frame.packet, luma: fold?.luma(for: frame.info?.luma)) } ready.append(contentsOf: buffered) buffered.removeAll(keepingCapacity: true) diff --git a/Sources/AetherEngine/Video/HLSSegmentProducer.swift b/Sources/AetherEngine/Video/HLSSegmentProducer.swift index 1e59b2543..ff9d21a79 100644 --- a/Sources/AetherEngine/Video/HLSSegmentProducer.swift +++ b/Sources/AetherEngine/Video/HLSSegmentProducer.swift @@ -563,6 +563,8 @@ final class HLSSegmentProducer: @unchecked Sendable { private var loggedEnhancementLayerType = false /// Latched false at SSAI program switch (ad creatives are H.264; mirrors muxer's isReinit ? false : videoConfig.convertP7ToProfile81). private var convertP7Active: Bool = false + /// `LoadOptions.foldsDolbyVisionFEL`; only `felLookaheadForRead()` reads it. + private let foldsDolbyVisionFEL: Bool #if DolbyVisionFelCurves /// Holds back one GOP at a time to fold a FEL source's enhancement layer into the RPU's curves /// (`FelCurveLookahead`). Created on the first read of a VOD P7 conversion without a side demuxer. @@ -1437,6 +1439,9 @@ final class HLSSegmentProducer: @unchecked Sendable { /// false instead of misreading the frozen fetch target as a wedge (issue #65 pause false-positive). var wantsToPlayProvider: (@Sendable () -> Bool)? + /// AE#1018: true while a folded RPU cannot reach the picture; `FelCurveLookahead` reads it per GOP. + var felFoldUnseenProvider: (@Sendable () -> Bool)? + /// #93 retest: reads AVPlayer's rendered (playlist-axis) position off the main actor. Feeds the /// backpressure wedge detector's fast path (park + flat clock + frozen fetch target -> single-digit /// detection instead of the 24 s counter). nil (tests, live) keeps the fast path inert. @@ -1627,9 +1632,11 @@ final class HLSSegmentProducer: @unchecked Sendable { audioMoovPrimeKnownUnobtainable: Bool = false, audioDelaySeconds: Double = 0, servesSegmentsProgressively: Bool = false, + foldsDolbyVisionFEL: Bool = true, epoch: UInt64 = 0 ) throws { self.epoch = epoch + self.foldsDolbyVisionFEL = foldsDolbyVisionFEL self.servesSegmentsProgressively = servesSegmentsProgressively self.audioDelaySeconds = audioDelaySeconds self.audioMoovPrimeFrame = audioMoovPrimeFrame @@ -3560,9 +3567,21 @@ final class HLSSegmentProducer: @unchecked Sendable { private func felLookaheadForRead() -> FelCurveLookahead? { if felLookaheadDecided { return felLookahead } felLookaheadDecided = true - guard convertP7Active, !isLive, videoPacketTapProvider?() == nil, - let stream = demuxer.stream(at: videoStreamIndex) else { return nil } - felLookahead = FelCurveLookahead(videoStreamIndex: videoStreamIndex, stream: stream, framing: a53NALFraming) + guard convertP7Active else { return nil } + let skipReason: String? = + !foldsDolbyVisionFEL ? "switched off for this load (LoadOptions.foldsDolbyVisionFEL)" + : isLive ? "live source" + : videoPacketTapProvider?() != nil ? "the enhancement-route tap needs the layer intact" + : nil + if let skipReason { + EngineLog.emit("[FelCurves] fold not armed: \(skipReason); a FEL source keeps the mode 2 mapping", + category: .session) + return nil + } + guard let stream = demuxer.stream(at: videoStreamIndex) else { return nil } + let unseen = felFoldUnseenProvider + felLookahead = FelCurveLookahead(videoStreamIndex: videoStreamIndex, stream: stream, framing: a53NALFraming, + foldIsUnseen: { unseen?() ?? false }) return felLookahead } diff --git a/Sources/AetherEngine/Video/HLSVideoEngine.swift b/Sources/AetherEngine/Video/HLSVideoEngine.swift index ddc3cc779..1eff68f38 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine.swift @@ -1,5 +1,6 @@ import AVFoundation import Foundation +import os import AetherLibavformat import AetherLibavcodec import AetherLibavutil @@ -1158,6 +1159,8 @@ public final class HLSVideoEngine: @unchecked Sendable { undecodableAudioStreamIndices: Set = [], audioBridgeMode: AudioBridgeMode = .surroundCompat, objectAudioRendering: ObjectAudioRendering = .off, + foldsDolbyVisionFEL: Bool = true, + panelPresentsHDR: Bool = false, stereoPresentation: StereoPresentation = .automatic, stereoOnAnySystem: Bool = false, isLiveSession: Bool = false, @@ -1218,6 +1221,8 @@ public final class HLSVideoEngine: @unchecked Sendable { self.undecodableAudioStreamIndices = undecodableAudioStreamIndices self.audioBridgeMode = audioBridgeMode self.objectAudioRendering = objectAudioRendering + self.foldsDolbyVisionFEL = foldsDolbyVisionFEL + self.panelPresentsHDRFlag = OSAllocatedUnfairLock(initialState: panelPresentsHDR) self.stereoPresentation = stereoPresentation self.stereoOnAnySystem = stereoOnAnySystem self.isLiveSession = isLiveSession @@ -1400,6 +1405,34 @@ public final class HLSVideoEngine: @unchecked Sendable { /// TrueHD Atmos and DTS:X delivery (#731, see `ObjectAudioRendering`); consulted before `audioBridgeMode`. let objectAudioRendering: ObjectAudioRendering + /// From `LoadOptions.foldsDolbyVisionFEL`; handed to every producer this session builds. + let foldsDolbyVisionFEL: Bool + + /// AE#1018: whether the panel presents HDR, for the FEL fold gate. Seeded at init with the load's + /// prediction (`AetherEngine.felGatePanelPresentsHDR`), set by an accepted HDR master or a settled + /// readout, cleared by a refused master (`notePanelPresentsHDR(_:)`). + private let panelPresentsHDRFlag: OSAllocatedUnfairLock + + func notePanelPresentsHDR(_ presents: Bool) { + let changed = panelPresentsHDRFlag.withLock { flag -> Bool in + defer { flag = presents } + return flag != presents + } + if changed, savedVideoConfig?.convertP7ToProfile81 == true { + EngineLog.emit("[FelCurves] panel presents HDR: \(presents)", category: .session) + } + } + + /// AE#1018, measured on an Apple TV 4K (tvOS 27.2) with an RPU edited to darken every other block: a + /// panel presenting HDR10 shows the base layer bit for bit, the RPU is not applied; a panel in SDR (media + /// or master playlist) and the AE#455 Profile 5 route both apply it. A Mac applies it too, so the + /// verdict is tvOS-only. The panel answer is `AetherEngine.felGatePanelPresentsHDR` at load, corrected + /// by readyToPlay of the HDR master and cleared by its refusal. + nonisolated static func felFoldIsUnseen(isTVOS: Bool, effectiveDvMode: Bool, servesAsProfile5: Bool, + panelPresentsHDR: Bool) -> Bool { + isTVOS && !effectiveDvMode && !servesAsProfile5 && panelPresentsHDR + } + /// Pre-opened demuxer reused by `start()` to skip `avformat_find_stream_info` (~1-3 s on slow CDN). /// Consumed in `start()`; unconsumed instances are closed by `stop()`. private var preopenedDemuxer: Demuxer? @@ -3844,6 +3877,7 @@ public final class HLSVideoEngine: @unchecked Sendable { // restart, live reopen, #99 revive) cuts with the offset currently in force. audioDelaySeconds: audioDelaySeconds, servesSegmentsProgressively: servesSegmentsProgressively, + foldsDolbyVisionFEL: foldsDolbyVisionFEL, epoch: producerEpoch ) // #240: threaded onto every producer (initial + restart), like the wedge-detector providers @@ -3872,6 +3906,17 @@ public final class HLSVideoEngine: @unchecked Sendable { // consumer issues no forward fetch; its frozen fetch target is not a wedge). Threaded onto every // producer (initial + restart) so the guard survives scrub/audio-switch rebuilds. prod.wantsToPlayProvider = playIntentProvider + #if os(tvOS) + let isTVOS = true + #else + let isTVOS = false + #endif + let dvMode = effectiveDvMode + let servesAsProfile5 = cfg.doviConfig == .rewriteToProfile5 + prod.felFoldUnseenProvider = { [panelPresentsHDRFlag] in + Self.felFoldIsUnseen(isTVOS: isTVOS, effectiveDvMode: dvMode, servesAsProfile5: servesAsProfile5, + panelPresentsHDR: panelPresentsHDRFlag.withLock { $0 }) + } // #93 retest: the rendered clock feeds the wedge detector's fast path (park + both signals // frozen -> single-digit detection). Threaded onto every producer like the play-intent guard. prod.playbackPositionProvider = currentPlaybackPositionProvider diff --git a/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift b/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift new file mode 100644 index 000000000..0c090961d --- /dev/null +++ b/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift @@ -0,0 +1,137 @@ +#if DolbyVisionFelCurves +import Testing +import Foundation +import AetherLibavcodec +@testable import AetherEngine + +/// AE#1018: the curve fold pauses where its RPU cannot reach the picture. +@Suite("AE#1018 FEL fold gate") +struct Issue1018FelFoldGateTests { + + @Test("only a tvOS panel proven in HDR, without Dolby Vision and off the Profile 5 route, hides the fold", + arguments: [ + (true, false, false, true, true), + (false, false, false, true, false), // a Mac applies the RPU + (true, true, false, true, false), // the display takes Dolby Vision + (true, false, true, true, false), // AE#455: AVPlayer composes the RPU + (true, false, false, false, false), // SDR or unproven panel: the RPU is applied + ]) + func verdict(_ row: (Bool, Bool, Bool, Bool, Bool)) { + #expect(HLSVideoEngine.felFoldIsUnseen(isTVOS: row.0, effectiveDvMode: row.1, servesAsProfile5: row.2, + panelPresentsHDR: row.3) == row.4) + } + + @Test("an HDR write with range matching on counts as HDR from the start, unless the display refused one", + arguments: [ + (false, true, true, false, true), + (false, true, true, true, false), // the display refused an HDR master in this process + (false, false, true, false, false), // matching off: the panel stays where it is + (false, true, false, false, false), // no HDR criteria written (SDR, or a host that owns them) + (true, false, false, true, true), // a proven panel wins over everything + ]) + func startingAnswer(_ row: (Bool, Bool, Bool, Bool, Bool)) { + #expect(AetherEngine.felGatePanelPresentsHDR(labelPresentsHDR: row.0, hdrWriteEngagesPanel: row.1, + engineWritesHDRCriteria: row.2, + panelRefusedHDRMaster: row.3) == row.4) + } + + /// Reads until the lookahead hands out its first video packet; returns how many source reads that took. + private static func readsToFirstVideo(unseen: Bool) throws -> (reads: Int, folding: Bool?) { + let demuxer = Demuxer() + try demuxer.open(url: HeavyDualLayerSuites.DoviFelRealFileTests.url("fel-clip-a.mkv")) + defer { demuxer.close() } + let index = Int32(demuxer.videoStreamIndex) + let stream = try #require(demuxer.stream(at: index)) + let lookahead = FelCurveLookahead(videoStreamIndex: index, stream: stream, framing: .lengthPrefixed(size: 4), + foldIsUnseen: { unseen }) + var reads = 0 + while let packet = try lookahead.next(read: { reads += 1; return try demuxer.readPacket() }) { + let isVideo = packet.pointee.stream_index == index + var p: UnsafeMutablePointer? = packet + trackedPacketFree(&p) + if isVideo { break } + } + let folding = lookahead.isFolding + lookahead.reset() + return (reads, folding) + } + + private static func rawReadsToFirstVideo() throws -> Int { + let demuxer = Demuxer() + try demuxer.open(url: HeavyDualLayerSuites.DoviFelRealFileTests.url("fel-clip-a.mkv")) + defer { demuxer.close() } + let index = Int32(demuxer.videoStreamIndex) + var reads = 0 + while let packet = try demuxer.readPacket() { + reads += 1 + let isVideo = packet.pointee.stream_index == index + var p: UnsafeMutablePointer? = packet + trackedPacketFree(&p) + if isVideo { break } + } + return reads + } + + private struct Stamp: Equatable { let stream: Int32; let pts: Int64; let dts: Int64 } + + private static func rawOrder(limit: Int) throws -> [Stamp] { + let demuxer = Demuxer() + try demuxer.open(url: HeavyDualLayerSuites.DoviFelRealFileTests.url("fel-clip-a.mkv")) + defer { demuxer.close() } + var out: [Stamp] = [] + while out.count < limit, let packet = try demuxer.readPacket() { + out.append(Stamp(stream: packet.pointee.stream_index, pts: packet.pointee.pts, dts: packet.pointee.dts)) + var p: UnsafeMutablePointer? = packet + trackedPacketFree(&p) + } + return out + } + + @Test("the fold leaves and rejoins at keyframes during playback, in read order", + .enabled(if: HeavyDualLayerSuites.DoviFelRealFileTests.available("fel-clip-a.mkv"), + HeavyDualLayerSuites.DoviFelRealFileTests.needsClips)) + func togglesBothWaysInOrder() throws { + let limit = 900 + let demuxer = Demuxer() + try demuxer.open(url: HeavyDualLayerSuites.DoviFelRealFileTests.url("fel-clip-a.mkv")) + defer { demuxer.close() } + let index = Int32(demuxer.videoStreamIndex) + let stream = try #require(demuxer.stream(at: index)) + var videoRead = 0 + // Folding at first, unseen from video packet 150 to 450, folding again after. + let lookahead = FelCurveLookahead(videoStreamIndex: index, stream: stream, framing: .lengthPrefixed(size: 4), + foldIsUnseen: { videoRead >= 150 && videoRead < 450 }) + var out: [Stamp] = [] + var states: [Bool] = [] + while out.count < limit, let packet = try lookahead.next(read: { + let p = try demuxer.readPacket() + if let p, p.pointee.stream_index == index { videoRead += 1 } + return p + }) { + out.append(Stamp(stream: packet.pointee.stream_index, pts: packet.pointee.pts, dts: packet.pointee.dts)) + states.append(lookahead.bypassing) + var p: UnsafeMutablePointer? = packet + trackedPacketFree(&p) + } + lookahead.reset() + #expect(out == (try Self.rawOrder(limit: out.count))) + let transitions = zip(states, states.dropFirst()).filter { $0 != $1 }.count + #expect(states.first == false) + #expect(states.contains(true)) + #expect(transitions == 2) + } + + @Test("while the fold is unseen the lookahead holds nothing back", + .enabled(if: HeavyDualLayerSuites.DoviFelRealFileTests.available("fel-clip-a.mkv"), + HeavyDualLayerSuites.DoviFelRealFileTests.needsClips)) + func bypassHoldsNothing() throws { + let folding = try Self.readsToFirstVideo(unseen: false) + let bypassed = try Self.readsToFirstVideo(unseen: true) + #expect(folding.folding == true) + #expect(bypassed.folding == true) + // Folding releases the first GOP only once the next keyframe has been read. + #expect(folding.reads > 10) + #expect(bypassed.reads == (try Self.rawReadsToFirstVideo())) + } +} +#endif diff --git a/docs/api.md b/docs/api.md index ccdd31c0d..a422f1d4d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1928,7 +1928,7 @@ Five parts of the engine are compiled only with their package trait, all off by | Symbol | Notes | | --- | --- | -| `AetherEngine.OptionalComponent` | `CaseIterable`. Cases: `.trueHDObjectAudio`, `.dtsxObjectAudio` (`objectAudioRendering` per codec), `.dualLayerDolbyVision` (`preferDualLayerDolbyVision`), `.dolbyVisionStillReshaping` (stills of Dolby Vision Profile 5 / AV1 Profile 10.0 sources), `.dolbyVisionFelCurves` (a Profile 7 FEL source's enhancement layer folded into the Profile 8.1 RPU on the native route, the stream's mapping kept and its luma curve lifted per scene; no option, on whenever built in). | +| `AetherEngine.OptionalComponent` | `CaseIterable`. Cases: `.trueHDObjectAudio`, `.dtsxObjectAudio` (`objectAudioRendering` per codec), `.dualLayerDolbyVision` (`preferDualLayerDolbyVision`), `.dolbyVisionStillReshaping` (stills of Dolby Vision Profile 5 / AV1 Profile 10.0 sources), `.dolbyVisionFelCurves` (a Profile 7 FEL source's enhancement layer folded into the Profile 8.1 RPU on the native route, the stream's mapping kept and its luma curve lifted per scene; on whenever built in, `foldsDolbyVisionFEL` turns it off per load, and it pauses on its own on a tvOS HDR10 panel without Dolby Vision, see [dolby-vision-fel.md §7](dolby-vision-fel.md#7-curve-fold-on-the-native-route)). | | `OptionalComponent.isBuiltIn` | Whether this build carries the component. Fixed for the process; hide a toggle whose component reads false. | | `OptionalComponent.traitName` | The package trait that compiles it, for a diagnostics screen. | @@ -2039,6 +2039,7 @@ Neither live startup option (`liveStartupGraceSeconds`, `liveStartupSingleSegmen | `dolbyVisionHandling` | `.automatic` | A `DolbyVisionHandling`. `.baseLayerOnly` presents the HDR10 / HLG base layer of a Dolby Vision source and leaves the Dolby Vision out of the container on every display. See [`dolbyVisionHandling`](#dolbyvisionhandling). | | `forceDolbyVisionOnNonDVDisplay` | false | **Experimental (AE#455).** Serve an HEVC Profile 8.1 source as Profile 5 on a display with no Dolby Vision of its own. See [`forceDolbyVisionOnNonDVDisplay`](#forcedolbyvisiononnondvdisplay). | | `preferDualLayerDolbyVision` | false | Opt in to composing Profile 7 FEL sources on the GPU. Not recommended for a distributed app. See [`preferDualLayerDolbyVision`](#preferduallayerdolbyvision). | +| `foldsDolbyVisionFEL` | true | With the `DolbyVisionFelCurves` trait: fold a Profile 7 FEL source's enhancement layer into the Profile 8.1 RPU's luma curve. False keeps the plain conversion and its start speed (#1018). Ignored without the trait. See [dolby-vision-fel.md §7](dolby-vision-fel.md#7-curve-fold-on-the-native-route). | | `dualLayerRoute` | `DualLayerRoute.softwareHost` | Which route composes a Profile 7 FEL picture once `preferDualLayerDolbyVision` has taken the dual-layer route. See [`dualLayerRoute`](#duallayerroute). | ### Decode and routing diff --git a/docs/dolby-vision-fel.md b/docs/dolby-vision-fel.md index 4cd30390c..9dc0dbe06 100644 --- a/docs/dolby-vision-fel.md +++ b/docs/dolby-vision-fel.md @@ -845,11 +845,23 @@ A scene without a residual (`disable_residual_flag`, MEL) or whose picture canno On macOS, `AVPlayerLayer` shows the folded stream served by `aetherctl serve` pixel-identical to the folded RPUs injected offline. On an M1 debug build the pass folds 60 s of FEL in 6 to 12 s. +**Where the folded RPU reaches the picture** (AE#1018). Measured on an Apple TV 4K (3rd generation, tvOS 27.2) at an HDR10 television without Dolby Vision, with a Profile 8.1 clip whose RPU darkens every other 2 s block, paused screenshots against the unedited clip: + +| Panel | Route | RPU applied | +|---|---|---| +| SDR (Match Content off) | media or master playlist | yes | +| HDR10 (Match Dynamic Range on) | master playlist | no, the base layer bit for bit | +| HDR10 | `forceDolbyVisionOnNonDVDisplay` (Profile 5) | yes, no colour shift on the unedited frames | + +A display that takes Dolby Vision and a Mac apply it as well. So the fold pauses in exactly one case: on tvOS, without Dolby Vision, off the Profile 5 route, while the panel presents HDR. The load predicts that from the start when range matching is on, the display can play HDR, the engine writes HDR criteria and no HDR master was refused in this process, because the readout taken during the display switch reads SDR on exactly these panels and the first producer would otherwise fold through the switch (measured: 0.7 s more to the first frame from a network source). An HDR master reaching readyToPlay confirms it; a refused master clears it. The check runs at every GOP keyframe. While it holds, packets pass through converted with the stream's mapping, no GOP is held back and nothing is decoded, and the log says `[FelCurves] fold paused`. A change of the answer during playback takes effect at the next keyframe in both directions. + +**Switching it off per load.** `LoadOptions.foldsDolbyVisionFEL` (default `true`) turns the fold off for one load, for a title a viewer reports or an A/B check on one build. It is a tuning field that `reloadAtCurrentPosition(applying:)` carries. Without the trait it is ignored. The load logs `[FelCurves] fold not armed: ` when a Profile 7 conversion runs without the fold. + ## 8. Document map | Document | Role | |---|---| | this file | design, implementation, evidence, verification | | [formats.md](formats.md#dolby-vision-signaling) | Profile 7 behaviour as a host sees it | -| [api.md](api.md) | `preferDualLayerDolbyVision`, `dolbyVisionHandling` | +| [api.md](api.md) | `preferDualLayerDolbyVision`, `dolbyVisionHandling`, `foldsDolbyVisionFEL` | | [cli.md](cli.md) | `feltest`, `dvsurvey`, `dovitest`, `play --cycle-el`, `felcurves` |