From 3599065daedda610e36320c6ebc0e42402403c68 Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Tue, 10 Nov 2026 21:02:20 +0100 Subject: [PATCH 1/5] feat(dv): pause the FEL curve fold where its RPU cannot reach the picture, add a per-load switch (#1018) Measured on an Apple TV 4K (tvOS 27.2) at an HDR10 television: with the panel in HDR10, tvOS shows the base layer bit for bit and ignores the RPU; a panel in SDR, the AE#455 Profile 5 route and a Mac apply it. The lookahead now re-reads that verdict at every GOP boundary and passes packets through with the stream's mapping while it holds, so such a display no longer pays the GOP hold-back and the per-scene decodes. LoadOptions.foldsDolbyVisionFEL (default true) turns the fold off per load; the load logs why a Profile 7 conversion runs without it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeT6NgMt1vv32kPBhUmsUz --- CHANGELOG.md | 17 ++++- .../AetherEngine/AetherEngine+Loading.swift | 11 ++- .../AetherEngine+ReloadWithOptions.swift | 2 +- Sources/AetherEngine/AetherEngine.swift | 4 +- Sources/AetherEngine/PlayerState.swift | 20 +++++ .../Video/FelCurves/FelCurveLookahead.swift | 57 +++++++++++--- .../Video/HLSSegmentProducer.swift | 25 ++++++- .../AetherEngine/Video/HLSVideoEngine.swift | 38 ++++++++++ .../Issue1018FelFoldGateTests.swift | 74 +++++++++++++++++++ docs/api.md | 3 +- docs/dolby-vision-fel.md | 14 +++- 11 files changed, 243 insertions(+), 22 deletions(-) create mode 100644 Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d30c69c3a..122e69cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,22 @@ 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, once the panel has proved it presents HDR, the fold now passes packets through from + the next keyframe on, 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. 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..636c02f4a 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -845,7 +845,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 +918,8 @@ extension AetherEngine { undecodableAudioStreamIndices: undecodableLiveAudioStreamIndices, audioBridgeMode: audioBridgeMode, objectAudioRendering: loadedOptions.objectAudioRendering, + foldsDolbyVisionFEL: loadedOptions.foldsDolbyVisionFEL, + panelPresentsHDR: panelPresentsHDR, stereoPresentation: loadedOptions.stereoPresentation, isLiveSession: isLive, dvrWindowSeconds: dvrWindowSeconds, @@ -1350,6 +1353,8 @@ extension AetherEngine { let routesAsHDRPanel: Bool do { routesAsHDRPanel = try await resolvePanelRouting() + session.notePanelPresentsHDR(Self.sessionPanelPresentsHDR( + hostAsserts: loadedOptions.panelIsInHDRMode, criteriaReadout: sessionPanelHDRReadout)) } catch { session.stop(reason: .loadFailed) throw error @@ -2865,7 +2870,9 @@ extension AetherEngine { preopenedDemuxer: customPreopened, keepsMediaPlaylistRoute: keepsMediaRoute, autostarts: transportIntentUnderReconstruction ?? resumesPlaying, - generation: gen + generation: gen, + panelPresentsHDR: Self.sessionPanelPresentsHDR( + hostAsserts: loadedOptions.panelIsInHDRMode, criteriaReadout: sessionPanelHDRReadout) ) EngineLog.emit("[AetherEngine] reload: loadNative done (\(elapsedMs(since: loadStart))ms)", category: .engine) playbackBackend = .native 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..e7a0ca8ed 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -6308,7 +6308,9 @@ public final class AetherEngine: ObservableObject { resolvePanelRouting: panelRoutingSettled ? nil : { try await settlePanelRouting(ownsProbe: false) return routingPanelHDR - } + }, + // AE#1018: false until settled; the resolver above updates the session. + panelPresentsHDR: panelRoutingSettled && panelHDRAfterHandshake ) playbackBackend = .native // Preserve source evidence for a later native-to-software recovery. diff --git a/Sources/AetherEngine/PlayerState.swift b/Sources/AetherEngine/PlayerState.swift index 962832d72..f9ca1c3aa 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 has proved it presents HDR10, 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..1dde3ea5d 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 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,6 +107,13 @@ final class FelCurveLookahead { if isFolding == false || (!isVideo && buffered.isEmpty) { return packet } + if isVideo, buffered.isEmpty, packet.pointee.flags & AV_PKT_FLAG_KEY != 0 || bypassing { + updateBypass() + } + if bypassing { + convert(packet, luma: nil) + return packet + } if isVideo, packet.pointee.flags & AV_PKT_FLAG_KEY != 0, bufferedHasVideo { release() } @@ -107,6 +123,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..04bc23478 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,27 @@ 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: the panel PROVED it presents HDR (host assertion or criteria readout, the label's + /// conservative answer, never the route's unproven attempt). Seeded at init, updated by + /// `notePanelPresentsHDR(_:)` once a running display switch has settled. + private let panelPresentsHDRFlag: OSAllocatedUnfairLock + + func notePanelPresentsHDR(_ presents: Bool) { + panelPresentsHDRFlag.withLock { $0 = presents } + } + + /// 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. Unknown counts as seen: the fold stays on until the panel has proved HDR. + 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 +3870,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 +3899,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..df21ee223 --- /dev/null +++ b/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift @@ -0,0 +1,74 @@ +#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) + } + + /// 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 + } + + @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..1d93b6410 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, once the panel has proved it presents HDR (a host assertion or the criteria readout; an unproven panel keeps the fold). The check runs at every GOP boundary. 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 panel whose answer arrives after playback started stops the fold from the next keyframe. + +**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` | From 52b9b7c6afc2eab6b6ddd5be10595f27e1ca1bfc Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Tue, 10 Nov 2026 21:11:27 +0100 Subject: [PATCH 2/5] fix(dv): let an accepted HDR master tell the FEL fold gate the panel presents HDR (#1018) The load-time panel readout is taken mid-switch and reads 1.00 on the Apple TV this was measured on, so the gate never closed. An HDR master that reaches readyToPlay was accepted, which a display presenting SDR refuses at parse time (AE#459). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeT6NgMt1vv32kPBhUmsUz --- Sources/AetherEngine/AetherEngine+Loading.swift | 7 +++++++ Sources/AetherEngine/AetherEngine.swift | 1 + Sources/AetherEngine/Video/HLSVideoEngine.swift | 8 +++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 636c02f4a..0c01869ec 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 diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index e7a0ca8ed..5dd231388 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -7791,6 +7791,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/Video/HLSVideoEngine.swift b/Sources/AetherEngine/Video/HLSVideoEngine.swift index 04bc23478..f086eed7d 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine.swift @@ -1414,7 +1414,13 @@ public final class HLSVideoEngine: @unchecked Sendable { private let panelPresentsHDRFlag: OSAllocatedUnfairLock func notePanelPresentsHDR(_ presents: Bool) { - panelPresentsHDRFlag.withLock { $0 = presents } + 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 From 93576db4e40590071754b50fe096fa8e416a10bc Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Tue, 10 Nov 2026 21:29:50 +0100 Subject: [PATCH 3/5] fix(dv): close the FEL fold gate from the first read when an HDR write will engage the panel (#1018) Measured on the Apple TV the gate was built for: from a source over the network the first load still paid ~0.7 s for the fold, because the first producer reads during the display switch and the panel answer only arrives at readyToPlay. With range matching on, an HDR-eligible display and an HDR criteria write from the engine, the panel is taken as presenting HDR from the start; a master the display refuses clears it on the session, and the fold resumes at the next keyframe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeT6NgMt1vv32kPBhUmsUz --- Sources/AetherEngine/AetherEngine+Loading.swift | 14 ++++++++++---- Sources/AetherEngine/AetherEngine+Probe.swift | 10 ++++++++++ Sources/AetherEngine/AetherEngine.swift | 9 +++++++-- .../Display/DisplayCriteriaController.swift | 12 ++++++++++++ .../Issue1018FelFoldGateTests.swift | 14 ++++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 0c01869ec..49f453b3d 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -1360,8 +1360,10 @@ extension AetherEngine { let routesAsHDRPanel: Bool do { routesAsHDRPanel = try await resolvePanelRouting() - session.notePanelPresentsHDR(Self.sessionPanelPresentsHDR( - hostAsserts: loadedOptions.panelIsInHDRMode, criteriaReadout: sessionPanelHDRReadout)) + if Self.sessionPanelPresentsHDR(hostAsserts: loadedOptions.panelIsInHDRMode, + criteriaReadout: sessionPanelHDRReadout) { + session.notePanelPresentsHDR(true) + } } catch { session.stop(reason: .loadFailed) throw error @@ -2878,8 +2880,12 @@ extension AetherEngine { keepsMediaPlaylistRoute: keepsMediaRoute, autostarts: transportIntentUnderReconstruction ?? resumesPlaying, generation: gen, - panelPresentsHDR: Self.sessionPanelPresentsHDR( - hostAsserts: loadedOptions.panelIsInHDRMode, criteriaReadout: sessionPanelHDRReadout) + 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..ff1e67ae8 100644 --- a/Sources/AetherEngine/AetherEngine+Probe.swift +++ b/Sources/AetherEngine/AetherEngine+Probe.swift @@ -1085,6 +1085,16 @@ extension AetherEngine { /// /// `nil` readout means suppressed, not false: the difference matters in the log, where a suppressed /// session has nothing to compare the assertion against. + /// 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) + } + nonisolated static func sessionPanelPresentsHDR(hostAsserts: Bool, criteriaReadout: Bool?) -> Bool { hostAsserts || (criteriaReadout ?? false) } diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 5dd231388..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( @@ -6309,8 +6311,11 @@ public final class AetherEngine: ObservableObject { try await settlePanelRouting(ownsProbe: false) return routingPanelHDR }, - // AE#1018: false until settled; the resolver above updates the session. - panelPresentsHDR: panelRoutingSettled && panelHDRAfterHandshake + 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. diff --git a/Sources/AetherEngine/Display/DisplayCriteriaController.swift b/Sources/AetherEngine/Display/DisplayCriteriaController.swift index 0cbf3a3c1..e2f846f82 100644 --- a/Sources/AetherEngine/Display/DisplayCriteriaController.swift +++ b/Sources/AetherEngine/Display/DisplayCriteriaController.swift @@ -1370,6 +1370,18 @@ final class DisplayCriteriaController { /// 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 /// previous session was still doing that. + /// 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 + } + func currentPanelIsHDR() -> Bool { #if os(tvOS) guard let window = resolveWindow() else { return false } diff --git a/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift b/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift index df21ee223..a130dfe71 100644 --- a/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift +++ b/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift @@ -21,6 +21,20 @@ struct Issue1018FelFoldGateTests { 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() From cd5ba78aa30efb9f060656cdb66c341cf5e3c530 Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Tue, 10 Nov 2026 21:47:45 +0100 Subject: [PATCH 4/5] fix(dv): leave the FEL fold bypass only at a keyframe, document the load-time panel prediction (#1018) Review findings: while paused the lookahead re-read the gate on every packet, so a refused master could resume the fold mid-GOP; it now re-reads it at keyframes only, as the log and the docs say. The docs still described the gate as waiting for a proven panel, which the load-time prediction replaced. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeT6NgMt1vv32kPBhUmsUz --- CHANGELOG.md | 8 +++++--- Sources/AetherEngine/PlayerState.swift | 2 +- .../AetherEngine/Video/FelCurves/FelCurveLookahead.swift | 2 +- Sources/AetherEngine/Video/HLSVideoEngine.swift | 9 +++++---- docs/dolby-vision-fel.md | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 122e69cfb..45f8adf7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,11 @@ the public-API contract. - **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, once the panel has proved it presents HDR, the fold now passes packets through from - the next keyframe on, 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. Logged as `[FelCurves] fold paused`. + 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/PlayerState.swift b/Sources/AetherEngine/PlayerState.swift index f9ca1c3aa..25ee15242 100644 --- a/Sources/AetherEngine/PlayerState.swift +++ b/Sources/AetherEngine/PlayerState.swift @@ -553,7 +553,7 @@ public struct LoadOptions: Sendable, Equatable { /// 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 has proved it presents HDR10, off the + /// 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. /// diff --git a/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift b/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift index 1dde3ea5d..961acc2d0 100644 --- a/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift +++ b/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift @@ -107,7 +107,7 @@ final class FelCurveLookahead { if isFolding == false || (!isVideo && buffered.isEmpty) { return packet } - if isVideo, buffered.isEmpty, packet.pointee.flags & AV_PKT_FLAG_KEY != 0 || bypassing { + if isVideo, buffered.isEmpty, packet.pointee.flags & AV_PKT_FLAG_KEY != 0 { updateBypass() } if bypassing { diff --git a/Sources/AetherEngine/Video/HLSVideoEngine.swift b/Sources/AetherEngine/Video/HLSVideoEngine.swift index f086eed7d..1eff68f38 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine.swift @@ -1408,9 +1408,9 @@ public final class HLSVideoEngine: @unchecked Sendable { /// From `LoadOptions.foldsDolbyVisionFEL`; handed to every producer this session builds. let foldsDolbyVisionFEL: Bool - /// AE#1018: the panel PROVED it presents HDR (host assertion or criteria readout, the label's - /// conservative answer, never the route's unproven attempt). Seeded at init, updated by - /// `notePanelPresentsHDR(_:)` once a running display switch has settled. + /// 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) { @@ -1426,7 +1426,8 @@ public final class HLSVideoEngine: @unchecked Sendable { /// 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. Unknown counts as seen: the fold stays on until the panel has proved HDR. + /// 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 diff --git a/docs/dolby-vision-fel.md b/docs/dolby-vision-fel.md index 1d93b6410..9dc0dbe06 100644 --- a/docs/dolby-vision-fel.md +++ b/docs/dolby-vision-fel.md @@ -853,7 +853,7 @@ On macOS, `AVPlayerLayer` shows the folded stream served by `aetherctl serve` pi | 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, once the panel has proved it presents HDR (a host assertion or the criteria readout; an unproven panel keeps the fold). The check runs at every GOP boundary. 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 panel whose answer arrives after playback started stops the fold from the next keyframe. +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. From 5a1f7ac1c719e3dfae86833367bbc867b4d2545d Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Tue, 10 Nov 2026 22:01:22 +0100 Subject: [PATCH 5/5] fix(dv): let a folding session enter the fold bypass at the next keyframe (#1018) Quint's review: while folding, the held GOP was still buffered when the next keyframe arrived, so the gate was never re-read and a session confirmed as HDR only at readyToPlay kept folding until a seek. A keyframe now releases the held GOP first and then reads the gate; a keyframe that starts the bypass queues behind that GOP instead of overtaking it. A test drives the gate both ways on a real FEL clip and checks the read order against the demuxer. The two new functions no longer sit between an existing doc comment and its function. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeT6NgMt1vv32kPBhUmsUz --- Sources/AetherEngine/AetherEngine+Probe.swift | 20 ++++---- .../Display/DisplayCriteriaController.swift | 8 +-- .../Video/FelCurves/FelCurveLookahead.swift | 15 ++++-- .../Issue1018FelFoldGateTests.swift | 49 +++++++++++++++++++ 4 files changed, 73 insertions(+), 19 deletions(-) diff --git a/Sources/AetherEngine/AetherEngine+Probe.swift b/Sources/AetherEngine/AetherEngine+Probe.swift index ff1e67ae8..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. /// @@ -1085,16 +1095,6 @@ extension AetherEngine { /// /// `nil` readout means suppressed, not false: the difference matters in the log, where a suppressed /// session has nothing to compare the assertion against. - /// 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) - } - nonisolated static func sessionPanelPresentsHDR(hostAsserts: Bool, criteriaReadout: Bool?) -> Bool { hostAsserts || (criteriaReadout ?? false) } diff --git a/Sources/AetherEngine/Display/DisplayCriteriaController.swift b/Sources/AetherEngine/Display/DisplayCriteriaController.swift index e2f846f82..193d0a7f0 100644 --- a/Sources/AetherEngine/Display/DisplayCriteriaController.swift +++ b/Sources/AetherEngine/Display/DisplayCriteriaController.swift @@ -1366,10 +1366,6 @@ final class DisplayCriteriaController { #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 - /// previous session was still doing that. /// 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. @@ -1382,6 +1378,10 @@ final class DisplayCriteriaController { #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 + /// previous session was still doing that. func currentPanelIsHDR() -> Bool { #if os(tvOS) guard let window = resolveWindow() else { return false } diff --git a/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift b/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift index 961acc2d0..7be383722 100644 --- a/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift +++ b/Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift @@ -62,7 +62,7 @@ final class FelCurveLookahead { /// 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 var bypassing = false + private(set) var bypassing = false init(videoStreamIndex: Int32, stream: UnsafeMutablePointer, framing: VideoNALFraming, foldIsUnseen: @escaping () -> Bool = { false }) { @@ -107,16 +107,21 @@ final class FelCurveLookahead { if isFolding == false || (!isVideo && buffered.isEmpty) { return packet } - if isVideo, buffered.isEmpty, packet.pointee.flags & AV_PKT_FLAG_KEY != 0 { + 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 } - if isVideo, packet.pointee.flags & AV_PKT_FLAG_KEY != 0, bufferedHasVideo { - release() - } buffered.append(packet) if isVideo { bufferedHasVideo = true } if buffered.count >= Self.maxBufferedPackets { release() } diff --git a/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift b/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift index a130dfe71..0c090961d 100644 --- a/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift +++ b/Tests/AetherEngineTests/Issue1018FelFoldGateTests.swift @@ -72,6 +72,55 @@ struct Issue1018FelFoldGateTests { 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))