Skip to content
Open
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
71 changes: 70 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,76 @@ the public-API contract.

## [Unreleased]

### Fixed
### Added

- **`AETHER_FEL_CHROMA` picks the chroma sampling of the composed Dolby Vision frame (`DualLayerDolbyVision`, experimental).**
The composition is defined on the 4:2:0 sites, and 4:2:0 stays the default and the output playback
uses. `AETHER_FEL_CHROMA=422` or `444` steps that result up to 4:2:2 or 4:4:4 by co-sited means (a copy
on a site, the mean of the sites around a sample between them), for an A/B run on a display. 4:4:4 is
experimental: the spec has no 4:4:4 reconstruction, so it only steps the 4:2:0 result up. Neither format
is checked on a TV yet, and both take the pool path (in-place composition is 4:2:0 only). Any other value
keeps 4:2:0. See `docs/dolby-vision-fel.md`.

### Changed

- **The `DualLayerDolbyVision` composer writes into the decoded base-layer buffer instead of a separate pool.**
When the base-layer buffer is IOSurface-backed, 10-bit biplanar video range, even-sized and the luma
curve is not MMR, the chroma pass and then the luma pass overwrite the buffer in place, so the
output pool is not allocated (a frame refused for in-place still takes the pool path). A buffer is
never composed twice at once, and a command buffer that fails after it started drops the frame
instead of showing a mix. Measured on the Mac (4K, 200 s, 7 seeks, two runs each): our process
falls by about 300 MB, the decoder service gains about 170 MB, a net fall of about 150 MB. The
composition is bit-identical to the pool path on the Mac (unit tests and a real-decoder spot check;
not yet checked on a TV). `AETHER_FEL_INPLACE=0` turns it off for an A/B run.
See `docs/dolby-vision-fel.md`.

- **The dual-layer route says in the log that an E-AC-3 track is decoded to PCM, and keeps session totals of the deadline releases (`DualLayerDolbyVision`).**
An E-AC-3 track played with a Profile 7 dual-layer video is decoded to PCM, which loses the Atmos
objects, with nothing in the log to say so; the producer now logs that once per session. The
dual-layer decoder also logs `totals:` (composed frames and the deadline, held-wall and backstop
releases, session-wide) at info level, at most every 10 s and only when a count moved, so a run's
log carries the numbers without a debug build.

### Fixed

- **A Dolby Vision FEL base frame whose enhancement layer does not arrive is released by its display deadline (`DualLayerDolbyVision`).**
A waiting base frame was given up on only after three newer base frames had arrived, so a damaged
or missing enhancement-layer stretch held the picture back and showed up as bursts of dropped
frames. The decoder now reads the render clock and releases the frame as its base layer once its
presentation time is within a margin of `maxFramesInFlight` frame intervals (at least 100 ms),
after a short grace (one frame interval, 15 to 50 ms) for an enhancement layer that is only a
moment late. The sweep re-arms on every arrival; a paused or prerolling clock holds the frames
(bounded at 2 s), and the old count rule stays as a backstop. On an A12 Apple TV with the
enhancement layer removed for 60 s of a clip the run dropped 1 frame, against 45 and 71 before (measured on an earlier debug build).

- **A Dolby Vision FEL frame shown as its base layer carries the RPU that keeps its mapping (`DualLayerDolbyVision`).**
A frame released by its display deadline, bypassed for a missing enhancement layer or RPU, or left
as its base layer by a failed composition went out with the composed-frame RPU, whose curves are
set to no-op because the composer is meant to have reshaped the picture. Nothing had, so the
display showed an unreshaped frame between composed ones. Those frames now carry the Profile 8.1
RPU with the source's mapping and display metadata. libdovi's mode 2 sets the curves to no-op only
when the RPU's own `el_type` is FEL, and that comes from the NLQ block, which is absent when
`disable_residual_flag` is set, so a composed frame's RPU now also has its mapping removed
whatever the `el_type`. The remux route is unchanged. The cost is a second libdovi parse and
conversion per RPU on the dual-layer route and one more NAL per side-data entry; when the
mapping-keeping form cannot be built the frame carries the composed form, as before. Keeping the
mapping on a raw base layer follows the fix for a missing enhancement layer in TheFireKahuna's AetherEngine fork (thanks
@TheFireKahuna); only its RPU half is taken here, the frame is still shown as its base layer.

- **The `DualLayerDolbyVision` composer covers the last chroma column of odd sizes, refuses a zero NLQ ceiling and pairs a passed enhancement slot by access unit.**
The chroma planes were sized with a floor division in the texture binds, the dispatch and the
kernel grid, so the last chroma column and row of an odd luma size was never written; they now
round up.
A `vdr_in_max` that shifts down to 0 now drops the residual like an out-of-range one, and the
prediction is clamped to the VDR range in float before the integer conversion, since an MMR result
far outside it overflowed int. When the base layer has only ever stepped forward, an enhancement
slot at or below the latest base picture that is not in the base time table pairs by access unit
like an unknown slot instead of taking an extrapolated time that belongs to no base frame. The
`el_type` probe rewinds its source with the same 5 s bounded seek the
interlace probe uses, and a stalled rewind leaves the type unknown, so the route is not taken. The
arrival-order path counts the frames its seek threshold discards (`skip=` in the cadence line,
separate from `lost=`) and logs the first one once per seek. Without the trait only the counter and
its log line exist, and nothing reaches them.

- **A 24.000 fps source is programmed as 24 Hz, not 23.976 Hz (AE#884, thanks @akhanwav).** `FrameRateSnap` sent
the whole 23.5 to 24.05 window to 23.976, so Match Frame Rate ran 24p content one frame in 1001 fast and the
Expand Down
11 changes: 10 additions & 1 deletion Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5413,7 +5413,16 @@ public final class AetherEngine: ObservableObject {
let vIdx = probe.videoStreamIndex
elType = await BlockingWork.detached(priority: .userInitiated) { [probe] in
let result = DolbyVisionRecordAudit.probeEnhancementLayerType(demuxer: probe, streamIndex: vIdx)
probe.seek(to: 0)
// The session reuses this demuxer, so a type read from a position that cannot be
// restored is worth nothing: a seek that stalls leaves the type unknown, and the
// route is not taken.
guard probe.seekBounded(to: 0, timeout: Self.probeRewindSeekTimeout) else {
EngineLog.emit(
"[AetherEngine] el_type probe could not rewind the source within "
+ "\(Int(Self.probeRewindSeekTimeout)) s; enhancement layer type unknown",
category: .engine)
return nil
}
return result
}.value
if loadGeneration != gen {
Expand Down
15 changes: 13 additions & 2 deletions Sources/AetherEngine/Decoder/DualLayerPictureAlignment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ struct DualLayerPictureAlignment {
/// First base-layer slot seen since the reset: with the latest it gives the time per slot.
private var origin: (slot: Int, pts: Int64)?
private var latest: (slot: Int, pts: Int64)?
/// Whether the base layer has decoded a picture below a slot it had already seen (a B-frame), so a
/// slot below `latest` that has no time yet can still be to come. Cleared by `reset`.
private var baseReordered = false

/// Units whose enhancement-layer picture was moved to another frame's time.
private(set) var reorderedUnits = 0
Expand All @@ -97,6 +100,7 @@ struct DualLayerPictureAlignment {
baseTime.removeAll()
origin = nil
latest = nil
baseReordered = false
}

/// Reads both layers' pictures from one access unit (base-layer NALs, and enhancement-layer NALs
Expand Down Expand Up @@ -131,6 +135,7 @@ struct DualLayerPictureAlignment {
for s in baseTime.keys where s < unit - 32 { baseTime.removeValue(forKey: s) }
}
if origin == nil { origin = (blSlot, pts) }
if let latest, blSlot < latest.slot { baseReordered = true }
latest = (blSlot, pts)
}

Expand All @@ -141,8 +146,14 @@ struct DualLayerPictureAlignment {
reorderedUnits += 1
return known
}
// That base-layer picture is still to come: extrapolate from the time per slot.
guard let origin, let latest, latest.slot != origin.slot,
// A slot beyond the latest base picture is still to come: extrapolate from the time per slot.
// A slot at or below it that is not in `baseTime` is still to come only when the base layer
// itself reorders (a B-frame decoded after a higher slot has been seen). With a base layer
// that has only ever stepped forward, such a slot was pruned or its base picture carried no
// POC: an extrapolated time would belong to no base frame, so it pairs by access unit like an
// unknown slot. A base layer is taken to reorder from its first B-frame on, so the unit before
// that one is paired by access unit as well.
guard let origin, let latest, elSlot > latest.slot || baseReordered, latest.slot != origin.slot,
let result = Self.extrapolate(origin: origin, latest: latest, slot: elSlot) else {
lastEnhancementPlaced = false; return pts
}
Expand Down
Loading
Loading