Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <reason>` 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

Expand Down
24 changes: 22 additions & 2 deletions Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions Sources/AetherEngine/AetherEngine+Probe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
2 changes: 1 addition & 1 deletion Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions Sources/AetherEngine/Display/DisplayCriteriaController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this doc block is inserted right after the existing /// lines that describe currentPanelIsHDR() ("headroom above 1.0 answers it ..."). Both now form one comment on hdrWriteExpectedToEngagePanel(), and currentPanelIsHDR() loses its doc. Move the new function above that comment block.

/// 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
Expand Down
20 changes: 20 additions & 0 deletions Sources/AetherEngine/PlayerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
66 changes: 52 additions & 14 deletions Sources/AetherEngine/Video/FelCurves/FelCurveLookahead.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,16 @@ final class FelCurveLookahead {
private(set) var scenesFailed = 0
private(set) var conversionFailures = 0

init(videoStreamIndex: Int32, stream: UnsafeMutablePointer<AVStream>, 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<AVStream>, framing: VideoNALFraming,
foldIsUnseen: @escaping () -> Bool = { false }) {
self.foldIsUnseen = foldIsUnseen
self.videoStreamIndex = videoStreamIndex
self.stream = stream
self.framing = framing
Expand Down Expand Up @@ -98,15 +107,54 @@ 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 }
if buffered.count >= Self.maxBufferedPackets { release() }
}
}

/// 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<AVPacket>, 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..<ready.count {
Expand Down Expand Up @@ -152,17 +200,7 @@ final class FelCurveLookahead {
}
let assignment = folds(for: frames)
for (frame, fold) in zip(frames, assignment) {
let luma = fold?.luma(for: frame.info?.luma)
// 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(
frame.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)
}
}
convert(frame.packet, luma: fold?.luma(for: frame.info?.luma))
}
ready.append(contentsOf: buffered)
buffered.removeAll(keepingCapacity: true)
Expand Down
Loading
Loading