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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ the public-API contract.
the bound port while `stop()` reset it under the lock. Host-only redirects now take the same lock,
and the port stays private to the server. Explicit `--redirect-port` targets are unchanged.

- **Hardware deinterlace warm-up starts only when a source needs it.** Engine construction and
progressive software playback no longer start or wait for a throwaway Metal/VideoToolbox graph.
Interlaced auto-mode loads still share one process-wide warm-up. Same-title custom reloads
preserve a decoded progressive verdict; actual title switches reassess reopened metadata.
Background teardown carries both interlaced and progressive decisions to a retained-reader reload.

- **Source failures keep their cause and request ownership.** Initial live probes preserve HTTP,
URL and TLS failures without changing the live-start `liveSourceUnavailable` kind (HTTP and
certificate classifications stay specific). A transport failure at open (a reset or a timeout from
Expand Down
38 changes: 33 additions & 5 deletions Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2049,6 +2049,7 @@ extension AetherEngine {
isLive: Bool = false,
dvrWindowSeconds: Double? = nil,
preopenedDemuxer: Demuxer?,
requiresDeinterlaceWarmup: Bool,
generation: UInt64,
elType: String? = nil
) async throws {
Expand All @@ -2057,10 +2058,11 @@ extension AetherEngine {
diagnostics.sessionCacheStatus = .inactive
let deinterlaceMode = loadedOptions.deinterlaceMode
let waitStarted = DispatchTime.now()
if let outcome = await DeinterlaceHardwareWarmup.shared.waitIfNeeded(
for: deinterlaceMode
) {
try checkLoadCurrent(generation)
let warmupOutcome = await DeinterlaceHardwareWarmup.shared.waitIfNeeded(
for: deinterlaceMode, sourceIsInterlaced: requiresDeinterlaceWarmup
)
try checkLoadCurrent(generation)
if let outcome = warmupOutcome {
let elapsed = Double(
DispatchTime.now().uptimeNanoseconds - waitStarted.uptimeNanoseconds
) / 1_000_000_000
Expand Down Expand Up @@ -2463,6 +2465,7 @@ extension AetherEngine {
discTitleIDOverride: Int? = nil,
resumeOverride: Double? = nil,
resumePlaying: Bool? = nil,
restoredDeinterlaceWarmupDecision: Bool? = nil,
keepsDisplayRefusedMediaRoute: Bool = false
) async -> Error? {
// Liveness guard: a stop()/load() between scheduling and here would resurrect a dismissed session or kill the successor. Generation captured at schedule time; both stop() and load() invalidate it.
Expand Down Expand Up @@ -2494,7 +2497,8 @@ extension AetherEngine {
}
// Disc title to reopen with: an explicit override (selectTitle on a custom disc) wins, else the title
// already playing so an audio switch / background-resume doesn't silently revert to the main title (#67).
let titleToReopen = discTitleIDOverride ?? activeDiscTitleID
let outgoingDiscTitleID = activeDiscTitleID
let titleToReopen = discTitleIDOverride ?? outgoingDiscTitleID
// resumeOverride 0 restarts a title switch at the new title's head; nil keeps the current playhead.
// AE#464 round 2: "the current playhead" is not `currentTime` while another load is in flight,
// which has already zeroed that clock. See `AetherEngine.rebuildPosition`.
Expand Down Expand Up @@ -2573,6 +2577,8 @@ extension AetherEngine {
}
// Preserve codec so the decoder label can be reconstructed without re-probing.
let preservedVideoCodec = lastDetectedVideoCodec
let preservedRequiresDeinterlaceWarmup =
restoredDeinterlaceWarmupDecision ?? lastRequiresDeinterlaceWarmup
let reloadStart = DispatchTime.now()
EngineLog.emit("[AetherEngine] reload: stopInternal start", category: .engine)
// resetDisplayCriteria: false: video format is unchanged; resetting triggers a full waitForSwitch Stage 2 timeout (5 s at the 2026-05-26 device test, ~2 s cap since #117; Bose SLIII A2DP + 4K HDR10 PQ: each switch added ~12 s black-screen). On the same route a panel SDR drop during the reset window failed the PQ variant with AVFoundationErrorDomain -11868 / CoreMediaErrorDomain -17223.
Expand Down Expand Up @@ -2623,6 +2629,7 @@ extension AetherEngine {
defer { endLoadInFlight(gen) }
loadedURL = url
lastDetectedVideoCodec = preservedVideoCodec
lastRequiresDeinterlaceWarmup = preservedRequiresDeinterlaceWarmup

// Custom sources: no URL to reopen; rebuild on the retained reader. Demuxer opens at byte 0; loadSoftware/loadNative seek to startPosition.
// Preserve the caller's probe budget (#68) across the reopen so an audio/title switch doesn't re-incur the full find_stream_info cost the caller paid to avoid.
Expand Down Expand Up @@ -2720,6 +2727,24 @@ extension AetherEngine {
}
}

// A same-title custom reload passes its title ID too. Keep any decoded progressive
// verdict for that title; only a different title invalidates the saved probe evidence.
// A background teardown already cleared the outgoing title; its same-source reload hands
// back the parked decision explicitly. An actual title switch never supplies that value.
let reopenedRequiresDeinterlaceWarmup: Bool = if restoredDeinterlaceWarmupDecision == nil,
titleToReopen != outgoingDiscTitleID,
let pre = customPreopened,
let stream = pre.stream(at: pre.videoStreamIndex),
let codecpar = stream.pointee.codecpar
{
VideoRoutingPolicy.requiresDeinterlaceWarmup(
codecID: codecpar.pointee.codec_id,
fieldOrder: codecpar.pointee.field_order,
spsIndicatesInterlaced: VideoRoutingPolicy.spsIndicatesInterlaced(codecParameters: codecpar))
} else {
preservedRequiresDeinterlaceWarmup
}

do {
let loadStart = DispatchTime.now()
if targetSoftwarePath {
Expand All @@ -2733,11 +2758,13 @@ extension AetherEngine {
isLive: loadedOptions.isLive,
dvrWindowSeconds: loadedOptions.dvrWindowSeconds,
preopenedDemuxer: customPreopened,
requiresDeinterlaceWarmup: reopenedRequiresDeinterlaceWarmup,
generation: gen,
elType: sourceElType
)
EngineLog.emit("[AetherEngine] reload: loadSoftware done (\(elapsedMs(since: loadStart))ms)", category: .engine)
playbackBackend = .software
lastRequiresDeinterlaceWarmup = reopenedRequiresDeinterlaceWarmup
activeAudioTrackIndex = audioStreamIndex.map { Int($0) }
activeVideoDecoder = Self.videoDecoderLabel(
codecID: preservedVideoCodec,
Expand Down Expand Up @@ -2819,6 +2846,7 @@ extension AetherEngine {
)
EngineLog.emit("[AetherEngine] reload: loadNative done (\(elapsedMs(since: loadStart))ms)", category: .engine)
playbackBackend = .native
lastRequiresDeinterlaceWarmup = reopenedRequiresDeinterlaceWarmup
// Publish the session's actual pick (invalid override falls back to auto; demuxed-audio resolves in side-demuxer numbering).
syncPublishedAudioStateFromNativeSession()
activeVideoDecoder = Self.videoDecoderLabel(
Expand Down
21 changes: 15 additions & 6 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2557,6 +2557,8 @@ public final class AetherEngine: ObservableObject {
/// Latched at load; reused by audio-track-switch reload to re-derive activeVideoDecoder without re-probing.
/// Reset to AV_CODEC_ID_NONE in stopInternal.
var lastDetectedVideoCodec: AVCodecID = AV_CODEC_ID_NONE
/// Preserved through same-source reloads; cleared when the session stops.
var lastRequiresDeinterlaceWarmup = false

/// In-flight probe demuxer. Registered before the detached open so stopInternal can markClosed() it:
/// without this, player dismissal/channel zapping left the probe reconnecting through subsequent sessions.
Expand Down Expand Up @@ -4021,7 +4023,6 @@ public final class AetherEngine: ObservableObject {
FFmpegLogBridge.install()
// Which FFmpeg answers is decided by the host's link, not by the package graph (AE#396).
FFmpegRuntimeCheck.logOnce()
_ = DeinterlaceHardwareWarmup.shared

// The category is declared off-main at the first load, once per process (#114), and the session
// is never activated here: AVKit owns native playback activation (#24).
Expand Down Expand Up @@ -5308,10 +5309,8 @@ public final class AetherEngine: ObservableObject {
if detectedCodecID == AV_CODEC_ID_H264, detectedFieldOrder == AV_FIELD_UNKNOWN,
probeOpened,
let vStream = probe.stream(at: probe.videoStreamIndex),
let codecpar = vStream.pointee.codecpar,
let extradata = codecpar.pointee.extradata, codecpar.pointee.extradata_size > 0 {
let bytes = Array(UnsafeBufferPointer(start: extradata, count: Int(codecpar.pointee.extradata_size)))
spsIndicatesInterlaced = VideoRoutingPolicy.spsIndicatesInterlaced(extradata: bytes)
let codecpar = vStream.pointee.codecpar {
spsIndicatesInterlaced = VideoRoutingPolicy.spsIndicatesInterlaced(codecParameters: codecpar)
if spsIndicatesInterlaced {
EngineLog.emit(
"[AetherEngine] fieldOrder=UNKNOWN but SPS frame_mbs_only_flag=0; "
Expand Down Expand Up @@ -5453,6 +5452,9 @@ public final class AetherEngine: ObservableObject {
canHardwareDecode: opensHardwareHEVC,
elType: elType
)
var requiresDeinterlaceWarmup = VideoRoutingPolicy.requiresDeinterlaceWarmup(
codecID: detectedCodecID, fieldOrder: detectedFieldOrder,
spsIndicatesInterlaced: spsIndicatesInterlaced)
// #232: a declared interlaced field order is not evidence that any frame IS interlaced.
// European 25 fps Blu-ray masters ship as 1080i25 (there is no 1080p25): interlaced carriage,
// progressive pictures, and FFmpeg's parser reports TT for them off SEI pic_struct alone while
Expand Down Expand Up @@ -5486,6 +5488,7 @@ public final class AetherEngine: ObservableObject {
}
if InterlaceProbe.refutesDeclaredInterlace(verdict) {
useSoftwarePath = false
requiresDeinterlaceWarmup = false
EngineLog.emit(
"[AetherEngine] declared interlaced (fieldOrder=\(detectedFieldOrder.rawValue)) but "
+ "\(verdict); the deinterlacer would never engage, routing native for HW decode (#232)",
Expand Down Expand Up @@ -5692,10 +5695,12 @@ public final class AetherEngine: ObservableObject {
isLive: options.isLive,
dvrWindowSeconds: options.dvrWindowSeconds,
preopenedDemuxer: probeOpened ? probe : nil,
requiresDeinterlaceWarmup: requiresDeinterlaceWarmup,
generation: gen,
elType: elType
)
playbackBackend = .software
lastRequiresDeinterlaceWarmup = requiresDeinterlaceWarmup
// AE#550: the one thing a viewer on a receiver needs said out loud. This backend's
// picture cannot leave the device, so a wireless route carries the sound alone, and
// the report that opened this was someone hearing a film they could not see.
Expand Down Expand Up @@ -5783,6 +5788,8 @@ public final class AetherEngine: ObservableObject {
}
)
playbackBackend = .native
// Preserve source evidence for a later native-to-software recovery.
lastRequiresDeinterlaceWarmup = requiresDeinterlaceWarmup
activeVideoDecoder = Self.videoDecoderLabel(
codecID: detectedCodecID, isSoftware: false
)
Expand Down Expand Up @@ -6034,7 +6041,8 @@ public final class AetherEngine: ObservableObject {
audioStreamIndex: selection.audioTrackIndex.map { Int32($0) },
expectedGeneration: loadGeneration,
discTitleIDOverride: selection.discTitleID,
resumePlaying: loadedOptions.autoplay
resumePlaying: loadedOptions.autoplay,
restoredDeinterlaceWarmupDecision: selection.requiresDeinterlaceWarmup
)
// AE#460 follow-up: a rebuild that died leaves the session in `.error`, and this branch
// used to return as if it had come back, so `reloadAtCurrentPosition(applying:)`
Expand Down Expand Up @@ -8167,6 +8175,7 @@ public final class AetherEngine: ObservableObject {
activeVideoDecoder = nil
activeAudioDecoder = nil
lastDetectedVideoCodec = AV_CODEC_ID_NONE
lastRequiresDeinterlaceWarmup = false
playlistShiftSeconds = 0
liveDisplayShiftSeconds = nil
// AE#105 / AE#270: the display origin belongs to the session that published it. Clearing it here
Expand Down
6 changes: 5 additions & 1 deletion Sources/AetherEngine/BackgroundTeardownSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ struct BackgroundTeardownSelection: Sendable, Equatable {
var subtitles = SubtitleSessionCarryover()
var audioTrackIndex: Int?
var discTitleID: Int?
/// nil means no loaded-session evidence; false is a verified decision worth retaining too.
var requiresDeinterlaceWarmup: Bool?
}

extension AetherEngine {
Expand Down Expand Up @@ -40,7 +42,9 @@ extension AetherEngine {
subtitles: Self.mergedSubtitleCarryover(
live: captureSubtitleSessionCarryover(), snapshot: parked?.subtitles),
audioTrackIndex: activeAudioTrackIndex ?? parked?.audioTrackIndex,
discTitleID: activeDiscTitleID ?? parked?.discTitleID
discTitleID: activeDiscTitleID ?? parked?.discTitleID,
requiresDeinterlaceWarmup: playbackBackend != .none
? lastRequiresDeinterlaceWarmup : parked?.requiresDeinterlaceWarmup
)
}

Expand Down
29 changes: 24 additions & 5 deletions Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,24 @@ final class DeinterlaceHardwareWarmup: @unchecked Sendable {
DeinterlaceFilter.prewarmHardwarePipeline() ? .ready : .unavailable
}

private let task: Task<Outcome, Never>
// The operation is immutable; every read/write of the shared task is under this lock.
// No lock is held across an await, and waiter cancellation does not cancel the shared task.
private let operation: Operation
private let lock = NSLock()
private var task: Task<Outcome, Never>?

init(operation: @escaping Operation) {
task = BlockingWork.detached(priority: .userInitiated) {
self.operation = operation
}

private func sharedTask() -> Task<Outcome, Never> {
lock.lock()
defer { lock.unlock() }
if let task {
return task
}
let operation = operation
let task = BlockingWork.detached(priority: .userInitiated) {
let started = DispatchTime.now()
EngineLog.emit(
"[Deinterlace] hardware warm-up started",
Expand All @@ -33,12 +47,17 @@ final class DeinterlaceHardwareWarmup: @unchecked Sendable {
)
return outcome
}
self.task = task
return task
}

func waitIfNeeded(for mode: DeinterlaceMode) async -> Outcome? {
guard mode == .auto else {
func waitIfNeeded(
for mode: DeinterlaceMode,
sourceIsInterlaced: Bool
) async -> Outcome? {
guard mode == .auto, sourceIsInterlaced else {
return nil
}
return await task.value
return await sharedTask().value
}
}
36 changes: 36 additions & 0 deletions Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,42 @@ enum VideoRoutingPolicy {
return fieldOrder == AV_FIELD_UNKNOWN && spsIndicatesInterlaced
}

/// Hardware deinterlace setup is unrelated to progressive software codecs such as VP8/VP9/AV1.
/// Start that relatively expensive Metal/VideoToolbox warm-up only when probe metadata says the
/// source can actually enter the deinterlacer.
static func requiresDeinterlaceWarmup(
codecID: AVCodecID,
fieldOrder: AVFieldOrder,
spsIndicatesInterlaced: Bool = false
) -> Bool {
// The decoder can deinterlace any codec whose frames carry the interlaced flag.
// Concrete probe evidence also covers DV, MPEG-1, H.263 and interlaced ProRes.
// A progressive declaration skips speculative work. If later mixed-cadence frames are
// interlaced, DeinterlaceFilter still builds its graph on demand at that first frame.
if interlacedFieldOrders.contains(fieldOrder) { return true }
switch codecID {
case AV_CODEC_ID_H264:
return fieldOrder == AV_FIELD_UNKNOWN && spsIndicatesInterlaced
case AV_CODEC_ID_MPEG4, AV_CODEC_ID_MPEG2VIDEO, AV_CODEC_ID_VC1:
// Shallow/live probes often leave legacy codecs UNKNOWN even though decoded frames later
// carry AV_FRAME_FLAG_INTERLACED. Warm conservatively for those codecs so the first such
// frame does not have to build the Metal/VideoToolbox graph inline.
return fieldOrder != AV_FIELD_PROGRESSIVE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question: this treats a probe-time AV_FIELD_PROGRESSIVE as final. MPEG-2 is where that can be wrong. Mixed-cadence DVDs and broadcast TS can report progressive from the first GOPs and carry AV_FRAME_FLAG_INTERLACED frames later. H.264 declared progressive and played on software for another reason (forced path, MVC, 4:2:2) has the same exposure.

Before this PR the construction-time warm-up covered that first interlaced frame. Now it builds the cold graph inline, measured at 35.6 ms for 480i in #492, about two frames at 59.94. If that matters, one option is to start the shared task without awaiting it on any .auto software load of these codecs, and await it only when the source is known to be interlaced. Progressive VP9/AV1 would still skip it entirely.

default:
return false
}
}

/// The same SPS tie-breaker for the initial probe and a reopened disc title. A concrete field
/// order wins; no borrowed codec-parameter storage escapes this synchronous classification.
static func spsIndicatesInterlaced(codecParameters: UnsafePointer<AVCodecParameters>) -> Bool {
let parameters = codecParameters.pointee
guard parameters.codec_id == AV_CODEC_ID_H264, parameters.field_order == AV_FIELD_UNKNOWN,
let extradata = parameters.extradata, parameters.extradata_size > 0 else { return false }
let bytes = Array(UnsafeBufferPointer(start: extradata, count: Int(parameters.extradata_size)))
return spsIndicatesInterlaced(extradata: bytes)
}

/// #150: pure extradata classifier feeding `spsIndicatesInterlaced`. Accepts Annex-B (MPEG-TS) and
/// avcC (MP4/MKV) extradata; anything unparseable classifies as not-interlaced so a missing or
/// malformed config never forces the software path.
Expand Down
Loading
Loading