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

## [Unreleased]

_Nothing yet._
### Added

- **A probe can identify HDR10+ before playback.** `AetherEngine.probe(url:detecting:)` takes a
`ProbeDetail` option set and runs the opt-in passes it names over one open handle: `.hdr10Plus`
scans demuxed video packets for ST 2094-40 carriage, `.atmos` is the bounded E-AC-3 JOC decode
that `probeDetectingAtmos` already ran (which stays, as a spelling of `detecting: .atmos`). Asking
for both costs one connection rather than two. `SourceProbe` gains `carriesHDR10PlusMetadata`, and
a source the container called HDR10 reads `.hdr10Plus` once the payload is seen, so a host can
label a title correctly on first play instead of waiting for the session's own mid-playback
upgrade. The scan opens no decoder: HDR10+ rides an in-band ITU-T T.35 SEI that no demuxer parses
(only `hevcdec` surfaces it, post-decode), and Matroska's `AV_PKT_DATA_DYNAMIC_HDR10_PLUS` side
data is read as the second carriage. Bounded by `HDR10PlusDetectionOptions` (32 packets, 16 MiB,
2 s) and additive: a cap leaves the base probe's answer exactly where it was, and a negative means
"not seen inside the budget", never "proven absent". `aetherctl probe` gained
`--detect-hdr10plus` and `--detect-atmos`. Suggested by Geordie.

## [7.8.1] - 2026-09-20

Expand Down
165 changes: 165 additions & 0 deletions Scripts/make-hdr10plus-fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Build a tiny HEVC/PQ MP4 that carries a real ST 2094-40 (HDR10+) T.35 SEI.

Layout of the payload follows libavutil/hdr_dynamic_metadata.c's parser exactly, so
`ffprobe -show_frames` reporting "HDR Dynamic Metadata SMPTE2094-40" is the proof the
fixture is genuine rather than a byte pattern that merely looks like one.
"""
import subprocess, sys, base64, os

class BitWriter:
def __init__(self):
self.bits = []
def u(self, n, value):
for i in range(n - 1, -1, -1):
self.bits.append((value >> i) & 1)
def bytes(self):
while len(self.bits) % 8:
self.bits.append(0)
out = bytearray()
for i in range(0, len(self.bits), 8):
byte = 0
for b in self.bits[i:i + 8]:
byte = (byte << 1) | b
out.append(byte)
return bytes(out)


def hdr10plus_payload():
w = BitWriter()
w.u(8, 0) # application_version
w.u(2, 1) # num_windows
w.u(27, 500 * 10000) # targeted_system_display_maximum_luminance (den 10000)
w.u(1, 0) # targeted_system_display_actual_peak_luminance_flag
# window 0
for maxscl in (17000, 16000, 15000):
w.u(17, maxscl)
w.u(17, 12000) # average_maxrgb
percentiles = [(1, 1000), (5, 2000), (10, 3000), (25, 5000), (50, 8000),
(75, 12000), (90, 16000), (95, 18000), (99, 20000)]
w.u(4, len(percentiles))
for pct, val in percentiles:
w.u(7, pct)
w.u(17, val)
w.u(10, 100) # fraction_bright_pixels
w.u(1, 0) # mastering_display_actual_peak_luminance_flag
# window 0 tone mapping
w.u(1, 1) # tone_mapping_flag
w.u(12, 1000) # knee_point_x
w.u(12, 1200) # knee_point_y
anchors = [100, 200, 300, 400, 500, 600, 700, 800, 900]
w.u(4, len(anchors))
for a in anchors:
w.u(10, a)
w.u(1, 0) # color_saturation_mapping_flag
body = w.bytes()
header = bytes([0xB5, 0x00, 0x3C, 0x00, 0x01, 0x04])
return header + body


def emulation_prevent(rbsp):
out = bytearray()
zeros = 0
for b in rbsp:
if zeros >= 2 and b <= 3:
out.append(0x03)
zeros = 0
out.append(b)
zeros = zeros + 1 if b == 0 else 0
return bytes(out)


def sei_nal(payload):
rbsp = bytearray()
rbsp.append(0x4E) # nal_unit_type 39 (PREFIX_SEI_NUT) << 1
rbsp.append(0x01) # nuh_layer_id 0, temporal_id_plus1 1
rbsp.append(0x04) # payload_type: user_data_registered_itu_t_t35
size = len(payload)
while size >= 255:
rbsp.append(0xFF)
size -= 255
rbsp.append(size)
rbsp += payload
rbsp.append(0x80) # rbsp_trailing_bits
return b"\x00\x00\x00\x01" + emulation_prevent(bytes(rbsp))


def split_annexb(data):
starts = []
i = 0
while i < len(data) - 3:
if data[i] == 0 and data[i + 1] == 0 and data[i + 2] == 1:
starts.append((i, 3))
i += 3
elif (i < len(data) - 4 and data[i] == 0 and data[i + 1] == 0
and data[i + 2] == 0 and data[i + 3] == 1):
starts.append((i, 4))
i += 4
else:
i += 1
nals = []
for idx, (pos, sclen) in enumerate(starts):
end = starts[idx + 1][0] if idx + 1 < len(starts) else len(data)
nals.append(data[pos:end])
return nals


def nal_type(nal):
body = nal.lstrip(b"\x00")
body = body[1:] # drop the 0x01 of the start code
return (body[0] >> 1) & 0x3F


def main():
out_dir = sys.argv[1]
plain = os.path.join(out_dir, "plain.mp4")
annexb = os.path.join(out_dir, "plain.hevc")
injected = os.path.join(out_dir, "injected.hevc")
fixture = os.path.join(out_dir, "hdr10plus-hevc.mp4")

subprocess.run([
"ffmpeg", "-y", "-v", "error",
"-f", "lavfi", "-i", "color=c=black:s=64x64:r=10:d=0.2",
"-c:v", "libx265", "-pix_fmt", "yuv420p10le",
"-x265-params", "log-level=none:info=0:keyint=1:min-keyint=1:colorprim=9:transfer=16:colormatrix=9",
"-color_primaries", "bt2020", "-color_trc", "smpte2084", "-colorspace", "bt2020nc",
"-frames:v", "2", plain,
], check=True)

subprocess.run([
"ffmpeg", "-y", "-v", "error", "-i", plain,
"-c:v", "copy", "-bsf:v", "hevc_mp4toannexb", "-f", "hevc", annexb,
], check=True)

data = open(annexb, "rb").read()
payload = hdr10plus_payload()
sei = sei_nal(payload)
out = bytearray()
for nal in split_annexb(data):
if nal_type(nal) <= 31: # VCL: the SEI must precede the slice in its access unit
out += sei
out += nal
open(injected, "wb").write(bytes(out))

subprocess.run([
"ffmpeg", "-y", "-v", "error", "-f", "hevc", "-i", injected,
"-c:v", "copy", "-tag:v", "hvc1", fixture,
], check=True)

probe = subprocess.run([
"ffprobe", "-v", "error", "-select_streams", "v:0", "-show_frames",
"-show_entries", "frame=side_data_list", fixture,
], capture_output=True, text=True)
ok = "SMPTE2094-40" in probe.stdout or "HDR10+" in probe.stdout
print(probe.stdout[:600])
print("SIZE:", os.path.getsize(fixture), "bytes")
print("HDR10+ PARSED BY FFMPEG:", ok)
if ok:
print("BASE64_START")
print(base64.b64encode(open(fixture, "rb").read()).decode())
print("BASE64_END")
return 0 if ok else 1


if __name__ == "__main__":
sys.exit(main())
111 changes: 97 additions & 14 deletions Sources/AetherEngine/AetherEngine+Probe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ extension AetherEngine {
options: LoadOptions = .init(),
atmosDetection: AtmosDetectionOptions = .init()
) throws -> SourceProbe {
try probeDetectingAtmos(source: .url(url), options: options, atmosDetection: atmosDetection)
try probe(source: .url(url), options: options, detecting: .atmos, atmosDetection: atmosDetection)
}

/// `probeDetectingAtmos(url:)` for a custom byte source. Same reader-ownership contract as `probe(source:)`:
Expand All @@ -91,6 +91,59 @@ extension AetherEngine {
source: MediaSource,
options: LoadOptions = .init(),
atmosDetection: AtmosDetectionOptions = .init()
) throws -> SourceProbe {
try probe(source: source, options: options, detecting: .atmos, atmosDetection: atmosDetection)
}

// MARK: - Bounded, opt-in detail probe

/// `probe(url:)` plus the bounded, OPT-IN passes named in `detecting`, over ONE open handle to the source.
///
/// Everything the base probe reports comes from the container. Two things a host wants to put on a
/// details screen are not in there:
///
/// - **Dolby Atmos**, which for E-AC-3 means the JOC flag in the dependent substream and only exists
/// post-decode (`.atmos`, see `AtmosDetectionOptions`), and
/// - **HDR10+**, whose ST 2094-40 metadata rides an in-band ITU-T T.35 SEI that no demuxer parses
/// (`.hdr10Plus`, see `HDR10PlusDetectionOptions`).
///
/// Both cost reads past `avformat_find_stream_info`, which is why `probe(url:)` does neither and stays
/// byte-for-byte what it was. Neither is for the playback-start critical path. Asking for both runs both
/// over one open, one connection: the HDR10+ scan first, out of the packets `find_stream_info` already
/// queued, then the queue-flushing seek the Atmos decode pass needs.
///
/// Both passes are additive and one-directional. They can only ever SET `isAtmos` /
/// `carriesHDR10PlusMetadata`, never clear what the container already declared, and a pass that hits a cap
/// leaves the base probe's answer exactly where it was.
///
/// - Parameters:
/// - url: Media source, forwarded verbatim to `probe(url:)`.
/// - options: Forwarded verbatim to `probe(url:)` (`httpHeaders` only).
/// - detecting: Which extra passes to run. Empty is exactly `probe(url:)`.
/// - atmosDetection: Bounds + optional track override for the Atmos decode pass. Ignored without `.atmos`.
/// - hdr10PlusDetection: Bounds for the HDR10+ scan. Ignored without `.hdr10Plus`.
/// - Throws: Only what `probe(url:)` throws (demuxer open / probe). Pass-side failures are never thrown:
/// they only mean a detail stays unconfirmed.
public nonisolated static func probe(
url: URL,
options: LoadOptions = .init(),
detecting: ProbeDetail,
atmosDetection: AtmosDetectionOptions = .init(),
hdr10PlusDetection: HDR10PlusDetectionOptions = .init()
) throws -> SourceProbe {
try probe(source: .url(url), options: options, detecting: detecting,
atmosDetection: atmosDetection, hdr10PlusDetection: hdr10PlusDetection)
}

/// `probe(url:detecting:)` for a custom byte source (SMB, WebDAV, a disc image, anything behind an
/// `IOReader`). Same reader-ownership contract as `probe(source:)`: the caller retains ownership, the
/// cursor is left at an unspecified position, and `close()` is NOT called.
public nonisolated static func probe(
source: MediaSource,
options: LoadOptions = .init(),
detecting: ProbeDetail,
atmosDetection: AtmosDetectionOptions = .init(),
hdr10PlusDetection: HDR10PlusDetectionOptions = .init()
) throws -> SourceProbe {
let demuxer = Demuxer()
let displayURL: URL
Expand All @@ -104,19 +157,37 @@ extension AetherEngine {
}
defer { demuxer.close() }

let base = makeSourceProbe(demuxer: demuxer, displayURL: displayURL)
// Flush what `avformat_find_stream_info` left queued before the decode pass starts. Those packets
// were read before `detectAtmos` sets AVDISCARD_ALL, so libavformat hands them back regardless of
// the hint: on a source whose audio does not sit at the head, the pass burns its whole foreign-packet
// fuse on that queue and reports "not Atmos" for genuinely Atmos media without ever reading a byte
// of audio. Seeking to the start discards the queue so the discard takes effect from the first read.
// A source that cannot seek is no worse off than before.
demuxer.seekBounded(to: 0, timeout: Self.atmosProbeFlushSeekTimeout)
let targetIndex = Self.atmosDecodeTargetIndex(
options: atmosDetection, defaultAudioStreamIndex: demuxer.audioStreamIndex)
let outcome = Self.detectAtmos(demuxer: demuxer, targetIndex: targetIndex, options: atmosDetection)
guard outcome.confirmedAtmos else { return base }
return Self.enrichAtmos(base: base, confirmedTrackID: Int(targetIndex))
var probe = makeSourceProbe(demuxer: demuxer, displayURL: displayURL)

// HDR10+ first, and before any seek: `avformat_find_stream_info` leaves its packets queued and
// `av_read_frame` hands those back first, so at the head of a container the scan gets video packets
// that have already been paid for. Running it after the Atmos pass would mean re-reading them.
if detecting.contains(.hdr10Plus) {
let outcome = Self.detectHDR10Plus(
demuxer: demuxer, videoIndex: demuxer.videoStreamIndex, options: hdr10PlusDetection)
if outcome.carriesHDR10Plus {
probe = Self.enrichHDR10Plus(base: probe)
}
}

if detecting.contains(.atmos) {
// Flush what is still queued before the decode pass starts. Those packets were read before
// `detectAtmos` sets AVDISCARD_ALL, so libavformat hands them back regardless of the hint: on a
// source whose audio does not sit at the head, the pass burns its whole foreign-packet fuse on
// that queue and reports "not Atmos" for genuinely Atmos media without ever reading a byte of
// audio. Seeking to the start discards the queue so the discard takes effect from the first read.
// A source that cannot seek is no worse off than before. (It also puts a source the HDR10+ scan
// has just walked back at the start.)
demuxer.seekBounded(to: 0, timeout: Self.atmosProbeFlushSeekTimeout)
let targetIndex = Self.atmosDecodeTargetIndex(
options: atmosDetection, defaultAudioStreamIndex: demuxer.audioStreamIndex)
let outcome = Self.detectAtmos(demuxer: demuxer, targetIndex: targetIndex, options: atmosDetection)
if outcome.confirmedAtmos {
probe = Self.enrichAtmos(base: probe, confirmedTrackID: Int(targetIndex))
}
}

return probe
}

/// Flip `isAtmos` to `true` on exactly the one confirmed audio track, leaving everything else identical.
Expand All @@ -136,6 +207,18 @@ extension AetherEngine {
return probe
}

/// Record HDR10+ carriage on a probe: set the flag, and move `videoFormat` if it is the one label the
/// payload describes (see `hdr10PlusUpgradedFormat`).
///
/// Mutates a copy rather than rebuilding the struct field by field: the memberwise init carries defaulted
/// parameters, so a hand-copy silently drops any field added later and the compiler stays quiet about it.
nonisolated static func enrichHDR10Plus(base: SourceProbe) -> SourceProbe {
var probe = base
probe.carriesHDR10PlusMetadata = true
probe.videoFormat = Self.hdr10PlusUpgradedFormat(base.videoFormat)
return probe
}

/// Assemble a `SourceProbe` from an open demuxer. Shared by static probe entry points and `load(source:)`'s internal probe stage so all report identical metadata.
nonisolated static func makeSourceProbe(
demuxer: Demuxer,
Expand Down
16 changes: 15 additions & 1 deletion Sources/AetherEngine/PlayerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -950,7 +950,10 @@ public struct SourceProbe: Sendable {
/// 0 for live streams / pipes.
public let durationSeconds: Double
/// `.sdr` when no HDR signaling or no video track.
public let videoFormat: VideoFormat
///
/// Settable inside the module so the `.hdr10Plus` upgrade from `probe(url:detecting: .hdr10Plus)` lands
/// here rather than rebuilding the struct field by field.
public internal(set) var videoFormat: VideoFormat
/// FFmpeg AVCodecID raw value; 0 (AV_CODEC_ID_NONE) when no video track.
public let videoCodecID: Int32
/// Codec name from libavcodec (e.g. "hevc", "h264", "av1"). nil when unavailable.
Expand All @@ -964,6 +967,15 @@ public struct SourceProbe: Sendable {
public let isDolbyVision: Bool
/// Dolby Vision profile number (5, 7, 8, 10) read from the dvcC/dvvC configuration record; nil when not DV.
public let dvProfile: Int?
/// HDR10+ (ST 2094-40) dynamic metadata was SEEN in this source's video.
///
/// Always `false` unless the probe was asked for `.hdr10Plus` (the container carries no such declaration,
/// so there is nothing to read without looking at packets). `false` therefore means "not asked, or not
/// seen inside the scan budget", never "proven absent": a positive is evidence, a negative is not.
///
/// Separate from `videoFormat == .hdr10Plus` because a Dolby Vision source can carry an HDR10+ layer too
/// (Blu-ray Profile 7 and the 8.1 remuxes of it), and that source keeps reading `.dolbyVision`.
public internal(set) var carriesHDR10PlusMetadata: Bool
/// Settable inside the module so `probeDetectingAtmos` can enrich one track without rebuilding the struct field by field.
public internal(set) var audioTracks: [TrackInfo]
/// Includes both text and bitmap (PGS / DVB) variants.
Expand All @@ -983,6 +995,7 @@ public struct SourceProbe: Sendable {
videoFrameRate: Double?,
isDolbyVision: Bool,
dvProfile: Int? = nil,
carriesHDR10PlusMetadata: Bool = false,
audioTracks: [TrackInfo],
subtitleTracks: [TrackInfo],
metadata: MediaMetadata = MediaMetadata(title: nil, artist: nil, album: nil, artworkData: nil),
Expand All @@ -998,6 +1011,7 @@ public struct SourceProbe: Sendable {
self.videoFrameRate = videoFrameRate
self.isDolbyVision = isDolbyVision
self.dvProfile = dvProfile
self.carriesHDR10PlusMetadata = carriesHDR10PlusMetadata
self.audioTracks = audioTracks
self.subtitleTracks = subtitleTracks
self.metadata = metadata
Expand Down
Loading
Loading