Skip to content

Releases: superuser404notfound/AetherEngine

AetherEngine 1.3.0

Choose a tag to compare

@superuser404notfound superuser404notfound released this 22 May 20:57

AetherEngine 1.3.0

Five days of focused work on the audio bridge, long-session memory, Dolby Vision dispatch, and the producer / cache layer. 137 commits since 1.2.0. The big throughline: every "memory grows over time" report root-caused (URLSession task pool retention, subtitle cue accumulation, periodic muxer recycle), and the audio bridge gained a soundbar-compatible default mode after the FLAC-only path downmixed to stereo on Sonos / Samsung HW-Q / Bose installations.

Audio bridge: two modes

The AudioBridge (TrueHD / DTS / DTS-HD MA / MP3 / Opus / Vorbis / PCM / MP2 decode + re-encode) gained a mode selector. Host picks via LoadOptions.audioBridgeMode:

  • .surroundCompat (new default): EAC3 at 128 kbps per channel. 256 kbps stereo, 768 kbps 5.1. AVPlayer hands the encoded bitstream to HDMI; the sink decodes its own surround mix. Works on every modern AVR and soundbar including the LPCM-stereo-only ones (Sonos Arc, Samsung HW-Q, Bose).
  • .lossless (opt-in): FLAC up to 7.1, lossless. AVPlayer decodes to LPCM and routes via the active HDMI port. Needs an AVR that accepts multichannel LPCM (Denon, Marantz, NAD high-end).

EAC3+JOC stream-copy bypasses the bridge entirely; Atmos passthrough intact.

Per-channel bitrate scales dynamically with the resolved channel count (DrHurt's pointer on issue #4). Drops Opus 2.0 / MP3 bridge overhead from 640 kbps flat to 256 kbps. When the upstream FFmpeg PR 21668 for 7.1 EAC3 lands, the channel cap bumps from 6 to 8 and the bitrate auto-engages 1024 kbps without a code change.

dec3 / dac3 from packet bitstream

The 1.2.0 path manually reconstructed dec3 / dac3 from the first AC3 / EAC3 syncframe in the host before feeding the muxer. Brittle around HDMV PGS and DV-only MKV variants. Replaced with the mp4 muxer's native +delay_moov flag: the moov atom is deferred until the first fragment cut, by which point libavformat's handle_eac3 / handle_ac3 populates the sample-entry from the actual packet bitstream. EAC3-from-MKV without pre-parsed extradata now stream-copies cleanly. Muxer flag set is the leak-free trio: +empty_moov+default_base_moof+frag_custom+delay_moov.

ec+3 for EAC3+JOC Atmos

EAC3+JOC stream-copy now advertises CODECS="ec+3" in the playlist (Apple HLS Authoring Spec marker for Atmos via DD+) rather than plain ec-3. Matches the dec3 box's JOC flag and keeps AVPlayer's downstream routing consistent.

Dolby Vision Profile 5: dvh1 + dvcC always

DV5 dispatch no longer downgrades the sample entry to hvc1 on non-DV-capable panels. The IPT-PQ-c2 elementary stream needs the DV decoder for color conversion, and AVPlayer cannot engage that decoder without the dvh1 sample entry. Per DrHurt's #19 finding ("dvh1 sample entry + media playlist = correct colours on every panel"), DV5 now emits dvh1.05.<level> + dvcC always; routing forces media playlist when the panel cannot engage DV mode (master with bare dvh1.05 is rejected by tvOS 26's strict master-level codec filter with -11868).

DV8.1 and DV8.4 dispatch unchanged.

Memory

Long-session heap: bounded

1.2.0 had slow leaks that hit jetsam after 8-13 min on 4K HDR HEVC at ~25 Mbps. Root-caused across four places:

AVIOReader URLSession task pool retention. The long-lived URLSession retained completion-handler response Data inside its internal task pool until invalidation, growing with the cumulative bytes fetched. Replaced with delegate-based incremental chunk fetch on a shared session: the task object releases per-chunk references after the delegate ack returns, no monolithic body accumulates. Verified bounded over 5-min 4K HEVC sessions: ~100-140 MB resident regardless of cumulative bytes.

Foundation Data CoW aliasing. Data.append(other) keeps a CoW reference to the source dispatch_data even after the append. Replaced with explicit withUnsafeMutableBytes + memcpy so the URLSession-side buffer is released cleanly per chunk.

Periodic demuxer recycle. A 30-second timer that recycled the libavformat demuxer to bound its internal heap turned out to be the leak source itself (the new demuxer kept the old AVIO context alive via libavformat's internal state). Removed; demuxer lifetime is now the session's.

Bitmap subtitle cue retention. Each PGS / DVB / DVD cue carried a CGImage whose CGDataProvider retained the decoded RGBA pixel buffer for the cue's lifetime. A 2h Blu-ray with PGS English (~1500-2000 cues) grew the heap by several hundred MB over the session. Cues are now pruned 300 s past current source-PTS; bounded at ~22 MB for typical PGS. Far backward scrubs that pass the retention window get cues back through producer-restart re-emit (EmbeddedSubtitleDecoder.resetState clears the dedupe set).

4 MB chunks via delegate fetch

AVIOReader chunk size went from 64 MB to 4 MB. Smaller chunks mean snappier cold-start (~1-3 s savings on first frame) without re-triggering the task pool leak (delegate fetch fixed that). Demuxer reuse from the probe step into the segment producer halved the cold-start path too.

Producer / cache

Backward window 5 → 20 + hole-wait

SegmentCache.backwardWindow expanded from 5 to 20 segments. With tvOS Continuous Audio Connection active, AVPlayer commonly refetches ~7-10 segments backward for audio gapless handover; the old 5-segment window made every such refetch a cache miss that triggered a producer restart, each one resetting the bridge encoder PTS and producing audible glitches. The cache-miss decision also gained a 2 s wait before declaring an in-range cache hole, breaking the 7-restart cascade observed when AVPlayer requested sequential segments faster than the new producer could write them.

Single session-wide muxer with +frag_custom

1.2.0 ran a fresh mp4 muxer per segment to clear DTS state. Replaced with a single muxer for the producer's lifetime that cuts fragments via av_write_frame(ctx, nil) and rotates segment files in the FragmentSplitter sink. Forward-only segment routing plus DTS-based lookup means no cross-fragment DTS regressions even on B-frame-heavy sources.

HEVC pre-keyframe leading B-frame (RASL) drops

HEVC open-GOP CRA leading B-frames before the first keyframe at a restart position are dropped now. Without that, AVPlayer would stall with -12860 on Bluey-style remuxes where the restart landed mid-GOP.

Audio gate / bridge encoder PTS

Gate target rescaled into source TB, not bridge encoder TB. The producer's audio scan-forward gate previously computed its target dts in audio.inputTimeBase. For stream-copy that equaled the source's TB. For the FLAC / EAC3 bridge it equaled the encoder TB (1/48000), while incoming packet.dts was in matroska's source TB (1/1000), so the gate target landed 48× too far into the source. Fixed by rescaling into the source TB.

Bridge encoder PTS rebases off packet pts, not frame pts. Codecs with decoder priming samples (Opus preskip, AAC encoder delay) advance frame.pts by the preskip count. Rebasing the bridge encoder timeline off the advanced frame.pts forward-shifted FLAC output by preskip-count and opened the audio gate ahead of the video gate, stalling AVPlayer in waitingToPlay. Use packet.pts instead so the encoded output matches the source timeline regardless of trim.

Other

  • Custom HTTP headers via LoadOptions.httpHeaders (carries through every demux + segment fetch).
  • AV1 native pipeline on HW-AV1 hosts (M3+ Mac, iPhone 15 Pro+, future Apple TV chips) with dav1 codec-tag wiring for AV1+DV. Software dav1d path stays for AV1 on tvOS + older devices and unconditionally for VP9.
  • engine.sourceTime published for the host overlay (= currentTime + playlistShiftSeconds) so subtitle cue rendering aligns with what is on screen regardless of which producer session is active.
  • New diagnostics: DV source side-data log, video track dump on readyToPlay, EAC3 multichannel-route warning, FLAC bridge surround-vs-route warning, AVPacket alloc/free balance counter for leak diagnostics.

Migration notes

  • LoadOptions.audioBridgeMode defaults to .surroundCompat. Hosts that want FLAC must set .lossless explicitly. Sodalite UI added a Settings toggle.
  • No public API removals.

Engine pin

For Sodalite hosts: bump Package.resolved to dcc1c57 (or use the 1.3.0 tag).

AetherEngine 1.2.0

Choose a tag to compare

@superuser404notfound superuser404notfound released this 17 May 06:54

AetherEngine 1.2.0

Point release on top of 1.1.0. Five fixes against three PTS-domain
bugs the 1.1.0 producer / bridge architecture surfaced once real
content with non-AAC audio and embedded PGS subtitles hit it. Plus
the diagnostics that made those bugs visible in the first place.

Audio (FLAC bridge)

Gate target rescaled into source TB, not bridge encoder TB. The
producer's audio scan-forward gate computed its target dts by
rescaling firstActualVideoDts into audio.inputTimeBase. For
stream-copy that was fine (inputTimeBase equals the source audio
stream's TB), but for the FLAC bridge inputTimeBase is the encoder
TB (1/48000) while incoming packet.dts is in the demuxer's source
TB (matroska's 1/1000). Net effect on a typical DTS-HD MA source:
the gate target landed 48× too far into the source, audio started
44 s after video on cold start, and producer restarts amplified to
~490 s of A/V drift. Symptom was a confusing pair of reports
("audio asynchron" + "Track-Switch lädt unendlich") that turned out
to be the same wrong rescale.

MP3 routed through the FLAC bridge. AudioCodecCompat used to
classify MP3 as fMP4-legal stream-copy and emit mp4a.40.34. The
fMP4 sample entry is spec-correct, but AVPlayer reads any mp4a
entry as AAC and fails to decode the MP3 frames with
AVFoundationErrorDomain -11829 (CoreMedia -12848) within a few
hundred ms of load. Same shape as the existing Opus workaround:
spec-legal in fMP4, rejected by AVPlayer downstream. Route MP3
through the bridge so AVPlayer gets fLaC instead. Trade-off: MP3
loses zero-overhead stream-copy, bridge cost on a lossy mono/stereo
source is negligible. Reported in #6 by @hp561.

Encoder PTS rebased off packet pts, not frame pts. For codecs
with decoder priming samples (Opus preskip, AAC encoder delay),
libavcodec's discard-samples path trims leading samples from the
first decoded frame AND advances frame.pts by the same amount.
AudioBridge rebased the FLAC encoder timeline off frame.pts, so
the first encoded sample's pts ended up preskip-shifted relative to
source-PTS=0. The producer's audio gate opened ahead of the video
gate and AVPlayer stalled in waitingToPlay waiting for an audio
segment that never lined up. Capture packet.pts at feed() entry
and use it for the rebase. Reported in #7 by @hp561, verified on
Apple TV 4K (3rd gen) with Opus-in-MKV.

Subtitles

Embedded subtitle cue.startTime is absolute source PTS seconds
again.
The decoder was subtracting AVStream.start_time from each
packet's PTS before emitting the cue, with a comment claiming the
subtraction produced "absolute source seconds matching AVPlayer's
clock". That assumption only held when the subtitle stream's
start_time equalled the video stream's. MKV doesn't behave that
way: PGS / SRT / SSA tracks have no continuous packet flow, so
AVStream.start_time on a subtitle stream is the PTS of the first
cue in the movie, often 19+ s in. Harry Potter 1: first PGS cue at
source PTS=19.186 s, streamStartTime=19186 → cue emitted with
startTime=0, displayed immediately at session-start, every
subsequent cue shifted forward by the same 19 s. Host filters cues
against engine.sourceTime already (= AVPlayer.currentTime + playlistShiftSeconds, both in absolute source PTS seconds), so the
decoder just needs to emit raw source PTS. Drop the offset entirely
and the parameter the caller no longer needs to plumb.

Diagnostics

Silent restart hangs in producer + reload path now surfaced via
EngineLog.
Two gaps were letting "lädt unendlich" / "Scrubbing
reagiert nicht" bugs reach the user without a paper trail:

  • HLSSegmentProducer's pre-gate drop loop counts skipped packets
    and emits still waiting for video keyframe: dropped=N target=X
    every 200 drops. If no IDR ever turns up that satisfies the
    restart target dts (open-GOP source with all CRAs sitting behind
    the target, or an MKV whose Cues lied about a packet being a
    keyframe), the failure mode is visible in Support → Logs instead
    of a black screen.
  • AetherEngine.reloadWithAudioOverride tags each phase with
    elapsed-ms markers: stopInternal done, loadNative enter /
    done, state=.playing total. The user-facing "frozen overlay
    during track switch" case now maps to a concrete phase in the log.

The matching audio-gate drop loop gets the same treatment, which is
how the FLAC-bridge gate TB mismatch above was diagnosed inside a
single playback session.

Engine pin

For Sodalite hosts: bump Package.resolved to f0c4198 (or use the
1.2.0 tag).

AetherEngine 1.1.0

Choose a tag to compare

@superuser404notfound superuser404notfound released this 16 May 16:56

AetherEngine 1.1.0

Bug-fix release on top of 1.0.0. Three days of public-beta feedback in Sodalite drove a producer-side A/V sync overhaul, HDR / Dolby Vision routing fixes, a dynamic subtitle-clock model, plus a handful of new public API hooks. Tag retargeted on 2026-05-16 to include the tvOS/iOS packaging fix (#5).

Playback pipeline (producer + muxer)

A/V sync after restart and from frame one. The producer's video gate is now unconditional on AV_PKT_FLAG_KEY, initial-start as well as restart sessions. The audio gate always waits for the video gate, so both streams' first kept sample comes from the same source-time. Previously, MKV remuxes whose first decode-order packet wasn't a sync sample (some Bluey BD remuxes) either stalled AVPlayer with -12860 or played the first few seconds with audio anchored at the file start and video at a later IDR.

Matroska seek imprecision tolerated end-to-end. Producer restarts scan forward from the matroska seek result to the next true IDR, then apply a per-stream dynamic PTS shift so the fragment tfdt lands at the playlist's cumulative-EXTINF origin. NOPTS dts repair (lastValidDts + 1) keeps B-frame-heavy MKVs from stalling the muxer. Per-stream PTS shift now uses each stream's own start_time rather than the format-level value, since broadcast remuxes can ship different per-stream offsets.

HEVC open-GOP CRA support. Producers now drop pre-keyframe leading B-frames (HEVC RASL) when their display-order pts lands before the CRA that opened the segment stream. Without the drop, AVPlayer's HEVC decoder fails on the first display sample and stalls in waitingToPlay forever (repro: Bombige Magenverstimmung, open-GOP HEVC with firstKeyframePts=88 and two leading B-frames at pts=88 and pts=131).

Per-frame fallback duration. When the source MKV doesn't ship DefaultDuration (HandBrake / web-rip pipelines often drop it), the producer backfills pkt->duration per stream via a look-behind. Stops the mp4 sub-muxer from writing trun.last.duration = 0, which broke seekability on some downstream consumers.

HDR / Dolby Vision routing

Match Content master-toggle aware HDR routing. HDR HEVC on a non-DV display now routes through the master playlist when the user's Match Content master toggle is on, and through the media playlist when it's off. On panels with the toggle off, the master's VIDEO-RANGE=PQ was failing AVPlayer with Cannot Open (AVFoundationErrorDomain -11848) since the panel is SDR-locked.

Dolby Vision P8.1 / P8.4 cross-compat tags. P8.1 and P8.4 now emit bare dvh1.<profile>.<dvLevel> on DV-capable displays for the direct DV engagement, and fall back to hvc1.2.4.LXX with SUPPLEMENTAL dvh1.08.LL/db4h for cross-player compatibility on P8.4's HLG-HEVC base layer.

SDR rate-only display criteria. DisplayCriteriaController.apply() used to early-return for .sdr sources, which also skipped the preferredDisplayCriteria assignment. SDR sessions now program a rate-only criteria so Match Frame Rate can engage independently of Match Dynamic Range. tvOS internally honours whichever Match Content sub-toggle the user has enabled.

HDR10+ runtime detection. videoFormat flips from .hdr10 to .hdr10Plus on first T.35 SEI detection in a packet's payload. Debounced once per session so producer restarts on scrub don't re-fire.

Effective format clamping. Published videoFormat is clamped to panel capabilities. A DV asset on a non-DV TV is reported as .hdr10 (the panel's actual experience) rather than the source's claimed format.

Subtitles

Cue timestamps mapped through the active producer's shift. Subtitle cues come from an independent side-demuxer in raw source PTS, but AVPlayer's HLS clock sits at source_pts - producer.videoShiftPts, and the shift varies per producer session (matroska seek imprecision means the shift can be ~4 s on restart sessions for the same source). The producer now reports its shift via onVideoShiftKnown; the engine forwards it through HLSVideoEngine.onPlaylistShiftChanged and publishes a derived sourceTime (= currentTime + shift). Hosts read sourceTime for cue lookup and side-demuxer seeking, while currentTime stays the AVPlayer-clock for transport / scrub / resume.

Public API additions

  • AetherEngine.currentAVPlayer (@Published AVPlayer?): exposes the active AVPlayer for MPNowPlayingSession hosting. Re-emitted on every reload so hosts that rebind MPNowPlayingSession.player stay current.
  • setExternalMetadata(_:): push AVMetadataItem collection into the engine for the next native load. Stashed before load() and replayed onto the freshly-created NativeAVPlayerHost when the item is created, so hosts can drive the tvOS info panel without reaching into AVPlayer.
  • LoadOptions.httpHeaders: custom headers (auth tokens, User-Agent overrides) attached to every demuxer + segment fetch, replayed across producer restarts and side-demuxer sessions.
  • LoadOptions.keepDvh1TagWithoutDV: experimental, keep the dvh1 codec tag on non-DV displays. Off by default; tooling lever for AVKit auto-criteria behaviour.
  • LoadOptions.matchContentEnabled: mirror the host's tvOS Match Content master toggle so the engine can gate HDR HEVC master-playlist routing accordingly.
  • engine.reloadAtCurrentPosition() preserves the original LoadOptions (httpHeaders + matchContentEnabled + keepDvh1TagWithoutDV) across the rebuild. Same for the internal audio-switch reload path.

Packaging

aetherctl is no longer exposed as an SPM product (#5). The target uses Foundation.Process, which is unavailable on tvOS / iOS, so exposing it forced SPM consumers on those platforms to compile it and fail. The aetherctl target itself stays, so swift build on macOS still produces the CLI for upstream development.

aetherctl

aetherctl reorganised under probe / serve / validate subcommands. serve exposes the engine's loopback HLS server for AVPlayer-side debugging on macOS. A fixtures.sh script populates a small media-zoo for repro testing.

Internals

  • HDR badge consolidated into the videoFormat subscription path, single source of truth.
  • EngineLog double-emit fixed (handler and stdout are now mutually exclusive so Xcode console no longer renders every line twice).
  • Network: BSD-sockets HLSLocalServer refactor attempted and reverted; NWConnection-based server remains the shipping implementation.
  • Cleanup: dead VP9 capability probe removed; dead symbols dropped; stale docstrings refreshed.

Engine pin

For Sodalite hosts: bump Package.resolved to 424b88e (or use the 1.1.0 tag).

AetherEngine 1.0.0

Choose a tag to compare

@superuser404notfound superuser404notfound released this 13 May 15:32

AetherEngine 1.0.0

First stable release. A video player engine for Apple platforms — drop the package in, hand it a file, get pixels on screen. Built on Swift 6 strict concurrency, LGPL 3.0 with App Store exception.

The engine handles the hard parts (HDR, Dolby Vision, Dolby Atmos, container coverage, codec coverage) and exposes a single render surface plus a handful of async methods. No AVPlayerViewController. No opinionated controls. No analytics. You ship the UI.

Architecture

Two playback pipelines coexist, picked once at load(url:) by the source's video codec and the device's decode capabilities. Hosts see a unified @Published state surface either way.

Native AVPlayer pipeline (default). Demux with libavformat, re-mux on the fly into HLS-fMP4, serve from a local HTTP loopback, point AVPlayer at the playlist. Apple's stack does all decode, HDR / Dolby Vision signaling, audio routing.

Source URL → Demuxer → HLSSegmentProducer → SegmentCache → HLSLocalServer
                                                                  ↓
                                                              AVPlayer
                                                                  ├→ VideoToolbox (HW)
                                                                  └→ AVR (Atmos via MAT 2.0)

Used for HEVC / H.264 in all cases, and for AV1 on devices with HW AV1 decoders (M3+ Mac, iPhone 15 Pro+, future Apple TV chips). Atmos passthrough, Dolby Vision HDMI handshake, HDR10 / HDR10+ / HLG all live on this path.

Software decoder pipeline (gap-filler). Demux, run video through libavcodec (dav1d for AV1, FFmpeg's native VP9 decoder for VP9) into CVPixelBuffers, run audio through libavcodec into CMSampleBuffers, render via AVSampleBufferDisplayLayer + AVSampleBufferAudioRenderer with AVSampleBufferRenderSynchronizer as the master clock.

Source URL → Demuxer ┬→ SoftwareVideoDecoder (dav1d / VP9) → SampleBufferRenderer → AVSampleBufferDisplayLayer
                     └→ AudioDecoder → AudioOutput → AVSampleBufferRenderSynchronizer (drives sync)

Used for codecs AVPlayer's HLS-fMP4 pipeline doesn't accept:

  • AV1 on devices without HW AV1 (all current Apple TV chips, M1/M2 Macs, pre-A17-Pro iPhones). Apple ships dav1d on macOS 14+ / iOS 17+ but it's only reachable via AVPlayer's HLS-fMP4 pipeline when the chip also has HW AV1 — verified empirically.
  • VP9 unconditionally. AVPlayer's HLS manifest parser silently rejects the vp09 CODECS attribute (verified via aetherctl: master.m3u8 + media.m3u8 fetched, then no further requests, item.status stays .unknown). VideoToolbox HW-decodes VP9 fine on A12+, but only outside the HLS pipeline.

Public API

  • AetherPlayerView (UIKit / AppKit) + AetherPlayerSurface (SwiftUI) — single render surface the host embeds. Polymorphic: hosts either AVPlayerLayer (native) or AVSampleBufferDisplayLayer (SW) per session, swapped automatically.
  • engine.bind(view:) / engine.unbind(view:) — engine attaches its active layer to the view automatically.
  • engine.load(url:options:) — single async entry point. Dispatches by codec internally. LoadOptions controls diagnostics-only toggles.
  • Transport: play(), pause(), togglePlayPause(), seek(to:) (async), setRate(_:), stop(), volume.
  • Lifecycle: reloadAtCurrentPosition() rebuilds the pipeline after background suspension.
  • Audio tracks: selectAudioTrack(index:) — mid-playback switch with backend-aware reload, audio-source-stream override propagates through both pipelines.
  • Subtitles: selectSubtitleTrack(index:) (embedded, runs a side demuxer at the playhead), selectSidecarSubtitle(url:) (sidecar SRT / ASS / VTT), clearSubtitle(). Text + bitmap unified via SubtitleCue (body = .text(String) or .image(SubtitleImage)).
  • Capabilities: AetherEngine.displayCapabilities — static snapshot of HDR / DV / HLG support.
  • @Published state: state, currentTime, duration, progress, audioTracks, subtitleTracks, activeAudioTrackIndex, videoFormat, playbackBackend (.native / .software / .none), subtitleCues, isLoadingSubtitles, isSubtitleActive.

Codec coverage

Codec Native path SW path
H.264 / AVC (SDR, HDR10) universal
HEVC / H.265 Main / Main10 (SDR / HLG / HDR10 / HDR10+) Apple TV 2017+ / iOS A9+ / Apple silicon
HEVC + Dolby Vision (P5 / P8.1 / P8.4) same hardware as HEVC; dvh1 / hvc1 track type + dvcC box
AV1 Main / High (P0 / P1, SDR / HDR10 / HDR10+) M3+ Mac, iPhone 15 Pro+ (HW AV1) Apple TV (all generations), older Mac / iPhone without HW AV1
AV1 + Dolby Vision (P10.0 / P10.1 / P10.4) same hardware as plain AV1; dav1 / av01 track type + dvvC box per Apple HLS Authoring Spec DV not engaged on the SW path (rare in real content; AV1+DV almost always pairs with HW-AV1 hosts)
VP9 Profile 0/2 (8/10-bit) all platforms (libavcodec native)
AV1 + DV P7 / DV P8.2 / DV P10.2 (SDR-base) / Profile 11+ refused (unsupportedDVProfile) refused

The native path's AV1 acceptance gates on VTCapabilityProbe.av1Available (strict VTIsHardwareDecodeSupported). Engine's load(url:) dispatch resolves the path per source; hosts don't see the distinction.

HDR pipeline

  • HDR10 / HDR10+ / HLG / Dolby Vision (HEVC P5, P8.1, P8.4 + AV1 P10.0, P10.1, P10.4) all engage the HDMI HDR-mode handshake on the native path.
  • AVDisplayCriteria built from real demuxer-probed format + r_frame_rate / avg_frame_rate (snapped to standard rates: 23.976 / 24 / 25 / 29.97 / 30 / 48 / 50 / 59.94 / 60).
  • Match Content / Match Frame Rate user settings honored.
  • HDR10+ ST 2094-40 metadata stream-copied as user-data-registered ITU-T T.35 SEI NALs; AVPlayer forwards the SEI to the system compositor unchanged.
  • Dolby Vision: codec tag promoted (hvc1dvh1 for HEVC P5 / P8.1, av01dav1 for AV1 P10.0 / P10.1) with the source's dvcC / dvvC box preserved. P8.4 / P10.4 keep the base codec tag and signal DV via SUPPLEMENTAL CODECS so non-DV displays present the HLG base layer.
  • Dolby Vision dual-layer (P7), SDR-base (P8.2 / P10.2) explicitly refused.
  • HDR-to-SDR mapping handled by AVPlayer and the system compositor; no host-side tonemap.

Audio

  • Stream-copy into fMP4 for legal codecs that AVPlayer accepts (AAC, AC3, EAC3 incl. JOC Atmos, FLAC, ALAC, MP3) — bit-exact, no transcode CPU overhead.
  • AudioBridge FLAC fallback for codecs that aren't legal in fMP4 (TrueHD, DTS, DTS-HD MA, PCM, MP2, Vorbis) or that AVPlayer rejects in HLS-fMP4 despite spec-legality (Opus). Decode → S16 PCM → FLAC re-encode. Lossless bed channels; for TrueHD-MAT and DTS-X Atmos sources the object metadata doesn't survive the PCM intermediate.
  • Atmos passthrough preserved via EAC3-JOC stream-copy. The engine emits explicit diagnostics on both success (stream-copy engaged, MAT 2.0 passthrough intact) and on the theoretical downgrade path (WARNING: Atmos downgrade — EAC3+JOC stream-copy rejected by mp4 muxer ...) so silent quality regressions are loud in the log.

Subtitles

Subtitle packets are routed through a side demuxer running at the playhead, decoded inline through avcodec_decode_subtitle2. Results land in a single [SubtitleCue] published list:

  • Text codecs (SubRip / ASS / SSA / WebVTT / mov_text) → SubtitleCue.body = .text(String). ASS override blocks stripped; \N becomes a real newline.
  • Bitmap codecs (PGS / HDMV PGS / DVB / DVD) → .image(SubtitleImage). Indexed pixel plane walked through its palette, premultiplied against alpha, wrapped as CGImage. Position normalised in [0..1] against the source frame so the host scales to any on-screen rect.
  • Sidecar files (separate .srt / .ass / .vtt URL) → selectSidecarSubtitle(url:) opens its own short-lived AVFormatContext, decodes the whole file once, atomically swaps the result into subtitleCues.

The host paints the cues with whatever style and animation it wants.

Seek

  • Native path: AVPlayer's own seek.
  • SW path: pause demux loop → flush decoders + renderer + audio renderer → seek demuxer → set skipUntilPTS so frames between keyframe-before-target and the target are dropped → jump the synchronizer clock atomically via AudioOutput.seekClock(to:rate:) so PTS-stamped samples decoded post-seek align with the master clock.
  • Backward / far-forward scrubs on the native path tear down the HLSSegmentProducer and restart it at the new segment base. Short-range forward scrubs ride the cached segment window without restart.

Streaming & resilience

  • HTTP Range + chunked delegate reads via URLSession. No third-party networking layer; TLS / HTTP-3 / proxies / MDM rules ride for free.
  • Exponential backoff on transient network errors.
  • Background pause / display-link aware lifecycle.

Dependencies

Package License Purpose
FFmpegBuild LGPL-3.0 Slim FFmpeg 7.1 (avcodec / avformat / avutil / swresample / swscale): demux + HLS-fMP4 mux + AudioBridge FLAC encode + SW-path dav1d / VP9 decode + sws_scale YUV → NV12 / P010
VideoToolbox System Native-path video decode (HW where available)
AVFoundation System AVPlayer + AVDisplayManager (native); AVSampleBufferDisplayLayer + AVSampleBufferRenderSynchronizer (SW)
CoreMedia System Sample descriptions, format-description tagging, CMTimebase

Non-goals

  • No built-in UI, controls, transport bar, HUD.
  • No analytics, telemetry, session reporting. Wire your own to the @Published state.
  • No playlist / queue management. Call load(url:) when you want the next one.
  • No subtitle overlay. The engine emits SubtitleCue; the host paints.
  • No Metal shaders. Everything renders through Apple's native display stack.
  • No third-party networking.

Requirements

| | Min |
|---|-...

Read more

Diagnostic bundle for issue #2 (2026-05-09)

Choose a tag to compare

Standalone HLS-fMP4 capture from aetherctl against a Jellyfin direct-play DV Profile 8.1 source. See README.md inside the zip for what each file is and how to test on iPhone Safari.

Not a code release. Pure diagnostic artifacts. Will probably be deleted once the underlying issue is resolved.