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
33 changes: 32 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,38 @@ the public-API contract.

## [Unreleased]

_Nothing yet._
### Added

- `AetherEngine.softwareDisplaySize`: the size the software path's picture
presents at, the coded frame under the pixel aspect ratio the decoder attached
(#353). A host laying an overlay out over the picture had only
`sourceVideoWidth` / `sourceVideoHeight`, which are the CODED size, so
anamorphic content was laid out against the wrong rectangle (720x576 at 64:45
presents as 1024x576), and `AVSampleBufferDisplayLayer` carries no `videoRect`
to measure instead. Nor could a host compute it: the ratio is resolved per
frame across three sources (#177) and one whose display aspect is impossible
is dropped in favour of square pixels (#290). Read off the format description
the renderer enqueues rather than recomputed from the SAR, so it cannot
disagree with the screen. nil off the software path and before the first
frame; it follows a mid-stream format change and is cleared with the session.

### Fixed

- Anamorphic HEVC on the software host rendered at its coded dimensions (#354).
The VT-backed decoder attached no pixel aspect ratio, and the renderer builds
its format description from the delivered pixel buffer, so nothing carried the
ratio to the layer: 720x576 declaring 64:45 presented as 720x576, a 16:9
picture squashed into 5:4. The libavcodec decoder on the same host has
attached it since #177, so the gap was one decoder wide. Resolved once at open
from the bitstream ratio and the container's, through the same #177 and #290
gates, and attached next to the colour metadata that is re-applied there for
the same reason. Reached in production by the interlaced-content detour and by
forward-only sources, which is where broadcast SD lands.
- The software load path cancelled every Combine sink it had already wired.
`softwareCancellables.removeAll()` stood between two groups of `.store(in:)`
calls, so the SW-PiP cue mirror never delivered a cue after the frame
compositor was armed, and subtitles in a software-path PiP window froze at
whatever was on screen when PiP started.

## [6.16.2] - 2026-08-09

Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,16 @@ player.softwarePresentationTimebase // the master clock, on the so
player.setSoftwareVideoFrameTimeObserver { frame in
frame.presentation; frame.generation
}

// The rectangle those frames land in (#353): coded dimensions under the pixel aspect ratio the
// decoder attached, read off the format description the renderer enqueues. `sourceVideoWidth` and
// `sourceVideoHeight` are the CODED size, so anamorphic content laid out against them is off by the
// pixel aspect (720x576 at 64:45 presents as 1024x576). nil off the software path and before the
// first frame; it follows a mid-stream format change and is cleared with the session.
player.softwareDisplaySize // CGSize?, @Published
```

Subtitle cues land in raw source PTS; render the overlay against `player.sourceTime` (see [docs/formats.md › Subtitles](docs/formats.md#subtitles)). A host compositing its own overlay onto the native path (libass and friends) needs the item axis too, since that is what the compositor pairs its samples against: `presentationAxisMap` converts arbitrary positions, `setNativeVideoFrameTimeObserver` reports the frames themselves. On the software path neither is needed: `softwarePresentationTimebase` hands out the clock the frames are presented against and `setSoftwareVideoFrameTimeObserver` reports them, both on the same axis as the cues. Both return nothing rather than a guess when no axis is established, because a defaulted shift is indistinguishable from a measured one at the call site. The 1 Hz diagnostics snapshot lives on `player.diagnostics.liveTelemetry`, off-the-engine for the same render-stability reason. Frame extraction, authored-ASS styling, and the full published surface are documented in [docs/formats.md](docs/formats.md).
Subtitle cues land in raw source PTS; render the overlay against `player.sourceTime` (see [docs/formats.md › Subtitles](docs/formats.md#subtitles)). A host compositing its own overlay onto the native path (libass and friends) needs the item axis too, since that is what the compositor pairs its samples against: `presentationAxisMap` converts arbitrary positions, `setNativeVideoFrameTimeObserver` reports the frames themselves. On the software path neither is needed: `softwarePresentationTimebase` hands out the clock the frames are presented against and `setSoftwareVideoFrameTimeObserver` reports them, both on the same axis as the cues, and `softwareDisplaySize` gives the rectangle to lay the overlay out in (the native path measures its own on `AVPlayerLayer.videoRect`). Both return nothing rather than a guess when no axis is established, because a defaulted shift is indistinguishable from a measured one at the call site. The 1 Hz diagnostics snapshot lives on `player.diagnostics.liveTelemetry`, off-the-engine for the same render-stability reason. Frame extraction, authored-ASS styling, and the full published surface are documented in [docs/formats.md](docs/formats.md).

Install via Swift Package Manager:

Expand Down
30 changes: 29 additions & 1 deletion Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,27 @@ extension AetherEngine {
hasFirstFrameReadyForDisplay = true
}

/// #353: mirror a software host's settled picture size onto the public `softwareDisplaySize`.
///
/// A mirror rather than the latch `hasFirstFrameReadyForDisplay` gets, because the two answer
/// different questions. A picture that exists cannot stop existing for the rest of the load, but
/// the size it presents at can change under it: a live source that switches resolution
/// mid-stream re-shapes the rectangle a host already laid out against, and a latched first value
/// would keep the overlay on the old one.
///
/// No `dropFirst()` here either, and that is a property of this path rather than a style choice:
/// the software path builds a new host per load (one construction site, and `stopInternal` nils
/// it), so what a fresh mirror replays is that host's own nil and not the outgoing item's size.
/// The native hosts, which are the ones reused across a load, have no size to mirror.
func mirrorSoftwareDisplaySize(
from publisher: Published<CGSize?>.Publisher,
storeIn cancellables: inout Set<AnyCancellable>
) {
publisher
.sink { [weak self] size in self?.softwareDisplaySize = size }
.store(in: &cancellables)
}

/// `videoReadyForDisplay` is the host's raw layer level (#315); nil on the audio hosts, which
/// have nothing to display. It is folded, never mirrored: the engine's published flag is latched
/// for the load, so the seams that reuse a host and briefly lose the picture do not surface.
Expand Down Expand Up @@ -1235,6 +1256,11 @@ extension AetherEngine {
}

activateRendererAudioSession(audioSourceStreamIndex: audioSourceStreamIndex)
// Drop the previous session's sinks BEFORE anything wires this one's. Standing further down,
// between two groups of `.store(in:)` calls, this cancelled everything wired above it: the
// SW-PiP cue mirror never delivered a cue after the frame compositor was armed. Both halves
// of such a wiring work in isolation, which is why a dead sink here reads as a working one.
softwareCancellables.removeAll()
let host = SoftwarePlaybackHost()
host.deinterlaceConfig = DeinterlaceConfig(
mode: loadedOptions.deinterlaceMode,
Expand All @@ -1251,6 +1277,9 @@ extension AetherEngine {
// #311: a load builds a new host and a new renderer, so an observer installed once by the
// host app has to be carried across the seam, exactly as the native session does at load.
host.setVideoFrameTimeObserver(softwareVideoFrameTimeObserver)
// #353: the settled picture size, wired next to the frame times because a host laying out an
// overlay needs the rectangle as well as the clock, and both come off this renderer.
mirrorSoftwareDisplaySize(from: host.$videoDisplaySize, storeIn: &softwareCancellables)
// SW-PiP: publish the bridge once the session owns its layer (the layer object is stable for
// the session; the host attaches it to the view and, on PiP start, to the system window).
softwarePiPSource = SoftwarePiPSource(layer: host.displayLayer, isLive: isLive, engine: self)
Expand Down Expand Up @@ -1296,7 +1325,6 @@ extension AetherEngine {
self.playlistShiftSeconds = 0
self.setPresentationAxis(PresentationAxisMap())

softwareCancellables.removeAll()
host.$currentTime
.sink { [weak self] value in
guard let self = self else { return }
Expand Down
27 changes: 27 additions & 0 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,30 @@ public final class AetherEngine: ObservableObject {
/// display layer, nil on teardown. Hosts build their sample-buffer PiP ContentSource from it.
@Published public internal(set) var softwarePiPSource: SoftwarePiPSource?

/// #353: the size the software path's picture presents at, in pixels: the coded frame under the
/// pixel aspect ratio the decoder attached. nil on every other path, before the first frame is
/// built, and on sources with no video.
///
/// What it is for is laying something out over the picture. A host derives the picture rect from
/// an aspect under the active `videoGravity`, and `sourceVideoWidth`/`sourceVideoHeight` are the
/// CODED dimensions, so anamorphic content lays out against the wrong rectangle: 720x576 at
/// 64:45 presents as 1024x576, and an overlay sized 5:4 sits inside a 16:9 picture. There is
/// nothing to measure on the layer either, since `AVSampleBufferDisplayLayer` has no `videoRect`
/// the way `AVPlayerLayer` does.
///
/// Nor can a host compute it. The ratio is resolved per frame across three sources, first sane
/// wins (#177), and one whose display aspect is impossible is dropped in favour of square pixels
/// (#290), so a host reconstructing it from container metadata disagrees with the screen in
/// exactly the cases that policy exists for. This is read off the format description the
/// renderer enqueues, so it is what the layer was handed rather than a second opinion about it.
///
/// Mirrored, not latched, unlike `hasFirstFrameReadyForDisplay`: a live source that switches
/// resolution mid-stream changes the shape of the picture under a host that already laid out
/// against it, and it is cleared with the session so the next source cannot be laid out against
/// this one's rectangle. The native and bypass paths mount an `AVPlayerLayer`, which measures its
/// own `videoRect` and carries `AVPlayerItem.presentationSize`; this stays nil there.
@Published public internal(set) var softwareDisplaySize: CGSize?

/// #288: the native-path counterpart of `softwarePiPSource.layer`. `AVPictureInPictureController`
/// wants the layer, not the player, so a host presenting its own PiP on the native path (tvOS has
/// no reachable AVKit affordance behind suppressed chrome) cannot get there from `currentAVPlayer`.
Expand Down Expand Up @@ -4769,6 +4793,9 @@ public final class AetherEngine: ObservableObject {
}

softwareCancellables.removeAll()
// #353: the picture belongs to the session. Left standing, the next source would be laid out
// against this one's rectangle for as long as it takes its own first frame to arrive.
softwareDisplaySize = nil
// #314: same detach on the software path, where the outgoing renderer's decode thread is what
// can still hand a frame over while the next host comes up.
softwareHost?.setVideoFrameTimeObserver(nil)
Expand Down
60 changes: 60 additions & 0 deletions Sources/AetherEngine/Decoder/HardwareVideoDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ final class HardwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable {
private var colorTransfer: CFString?
private var colorMatrix: CFString?

/// #354: the stream's pixel aspect ratio, re-applied to every CVPixelBuffer for the same reason
/// the colorimetry is: nothing else puts it there. The renderer builds its format description
/// from the delivered buffer, so a ratio that is not an attachment on that buffer never reaches
/// the layer, and anamorphic content is displayed at its coded dimensions. nil for square pixels
/// and for a ratio the policy rejects, which is the case where coded dimensions ARE correct.
///
/// Resolved once at `open()`, not per frame: VT delivers pixel buffers rather than `AVFrame`s, so
/// the per-frame source `SoftwareVideoDecoder` prefers does not exist here. That also makes the
/// #177 latch unnecessary, since one resolution cannot oscillate.
private var pixelAspectRatio: AVRational?

/// Protects `session` across the demux thread (decode), main thread (close/flush), and VT callback (delivery).
private let lock = NSLock()

Expand All @@ -86,6 +97,26 @@ final class HardwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable {
width = codecpar.pointee.width
height = codecpar.pointee.height

// #354: both declared sources, because only one of them is the container's. The bitstream
// ratio reaches codecpar, while a container-declared one reaches AVStream alone (Matroska's
// DisplayWidth quotient, MP4's `pasp`), which is where every DVD remuxed to MKV carries it.
pixelAspectRatio = Self.resolvePixelAspectRatio(
bitstream: codecpar.pointee.sample_aspect_ratio,
container: stream.pointee.sample_aspect_ratio,
width: width,
height: height
)
if let sar = pixelAspectRatio {
EngineLog.emit(
"[HWDecoder] SAR \(sar.num):\(sar.den) on \(width)x\(height) "
+ "(bitstream=\(codecpar.pointee.sample_aspect_ratio.num):"
+ "\(codecpar.pointee.sample_aspect_ratio.den) "
+ "container=\(stream.pointee.sample_aspect_ratio.num):"
+ "\(stream.pointee.sample_aspect_ratio.den))",
category: .swPlayback
)
}

guard codecpar.pointee.codec_id == AV_CODEC_ID_HEVC else {
throw VideoDecoderError.unsupportedCodec(id: codecpar.pointee.codec_id.rawValue)
}
Expand Down Expand Up @@ -319,6 +350,22 @@ final class HardwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable {
close()
}

// MARK: - Pixel aspect ratio (#354)

/// The ratio to attach, or nil when there is nothing to correct. Bitstream first, container
/// second (`declaredStreamSAR`), then the same two gates the libavcodec path runs: the #177
/// component bound and the #290 display aspect the ratio produces on this frame. Square pixels
/// return nil rather than 1:1, because attaching a correction of one is a correction a consumer
/// cannot tell from a real one.
static func resolvePixelAspectRatio(
bitstream: AVRational, container: AVRational, width: Int32, height: Int32
) -> AVRational? {
let declared = SoftwareVideoDecoder.declaredStreamSAR(bitstream: bitstream, container: container)
guard let sane = PixelAspectPolicy.saneSAR(declared, width: width, height: height),
sane.num != sane.den else { return nil }
return sane
}

// MARK: - Callback handling (called from VT's queue)

/// Invoked by `hwDecoderOutputCallback`; delivers CVPixelBuffer+PTS, honouring `skipUntilPTS` for seek-pre-roll.
Expand All @@ -345,6 +392,19 @@ final class HardwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable {
CVBufferSetAttachment(imageBuffer, kCVImageBufferYCbCrMatrixKey, matrix, .shouldPropagate)
}

// #354: without this the renderer's format description carries no pixel aspect ratio and
// anamorphic content is displayed at its coded dimensions.
if let sar = pixelAspectRatio {
let aspect: NSDictionary = [
kCVImageBufferPixelAspectRatioHorizontalSpacingKey: Int(sar.num),
kCVImageBufferPixelAspectRatioVerticalSpacingKey: Int(sar.den),
]
CVBufferSetAttachment(imageBuffer, kCVImageBufferPixelAspectRatioKey, aspect, .shouldPropagate)
} else {
// A recycled pool buffer can carry a stale attachment from an earlier stream.
CVBufferRemoveAttachment(imageBuffer, kCVImageBufferPixelAspectRatioKey)
}

onFrame?(imageBuffer, pts, nil)
}
}
Expand Down
24 changes: 24 additions & 0 deletions Sources/AetherEngine/Native/SoftwarePlaybackHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ final class SoftwarePlaybackHost {
/// with the session.
private var readyForDisplayObserver: NSObjectProtocol?

/// #353: the size this session's picture presents at, coded dimensions under the pixel aspect
/// ratio the decoder attached; nil until the renderer builds its first sample buffer and on
/// sources with no video. The engine mirrors it as `AetherEngine.softwareDisplaySize`, which is
/// what hosts read; this host is built per load, so it starts unknown by construction.
@Published private(set) var videoDisplaySize: CGSize?

/// Fires (off-main) once per session the first time HDR10+ dynamic
/// metadata appears on a decoded frame. Hooked by `AetherEngine` to
/// upgrade the published `videoFormat` from `.hdr10` → `.hdr10Plus`.
Expand Down Expand Up @@ -458,6 +464,24 @@ final class SoftwarePlaybackHost {
// Default to the software decoder; load() swaps it for the
// VT-backed one when the source's video codec is HEVC.
self.videoDecoder = SoftwareVideoDecoder()
armDisplaySizeObserver()
}

/// #353: the renderer settles the picture size on the decode thread, where it builds the format
/// description; publish it on the main actor like every other mirror on this host. Armed in init
/// rather than at load: the renderer is this host's own and lives exactly as long as it does.
private func armDisplaySizeObserver() {
renderer.setDisplaySizeObserver { [weak self] size in
Task { @MainActor in
guard let self, self.videoDisplaySize != size else { return }
self.videoDisplaySize = size
EngineLog.emit(
"[SWHost] picture settles at \(Int(size.width))x\(Int(size.height)) "
+ "after \(self.framesEnqueued) frames",
category: .swPlayback
)
}
}
}

// MARK: - Audio stream resolution (#133)
Expand Down
Loading
Loading