From f5fd4a7ad49e7af9bbe21e2b35928387c9eaa4d9 Mon Sep 17 00:00:00 2001 From: JonLuca DeCaro Date: Sun, 25 Oct 2026 09:28:07 -0700 Subject: [PATCH 1/3] perf(video): defer deinterlace warmup until needed --- CHANGELOG.md | 4 ++ .../AetherEngine/AetherEngine+Loading.swift | 42 +++++++++++++++++-- Sources/AetherEngine/AetherEngine.swift | 12 +++++- .../Decoder/DeinterlaceHardwareWarmup.swift | 29 ++++++++++--- .../Decoder/VideoRoutingPolicy.swift | 24 +++++++++++ .../Issue203SoftwareColdStartTests.swift | 28 ++++++++++--- .../Issue232InterlaceProbeTests.swift | 23 ++++++++++ .../VideoRoutingPolicyTests.swift | 29 +++++++++++++ docs/architecture.md | 2 +- 9 files changed, 177 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f7a9fb8f..2d07a6c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,6 +168,10 @@ 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, including audio/title reloads. + - **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 diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 5a4235159..cc2623069 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -2049,6 +2049,7 @@ extension AetherEngine { isLive: Bool = false, dvrWindowSeconds: Double? = nil, preopenedDemuxer: Demuxer?, + requiresDeinterlaceWarmup: Bool, generation: UInt64, elType: String? = nil ) async throws { @@ -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 @@ -2573,6 +2575,7 @@ extension AetherEngine { } // Preserve codec so the decoder label can be reconstructed without re-probing. let preservedVideoCodec = lastDetectedVideoCodec + let preservedRequiresDeinterlaceWarmup = 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. @@ -2623,6 +2626,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. @@ -2720,6 +2724,33 @@ extension AetherEngine { } } + let reopenedRequiresDeinterlaceWarmup: Bool = if discTitleIDOverride != nil, + let pre = customPreopened, + let stream = pre.stream(at: pre.videoStreamIndex), + let codecpar = stream.pointee.codecpar + { + if codecpar.pointee.codec_id == AV_CODEC_ID_H264, + codecpar.pointee.field_order == AV_FIELD_UNKNOWN, + let extradata = codecpar.pointee.extradata, + codecpar.pointee.extradata_size > 0 + { + VideoRoutingPolicy.requiresDeinterlaceWarmup( + codecID: codecpar.pointee.codec_id, + fieldOrder: codecpar.pointee.field_order, + spsIndicatesInterlaced: VideoRoutingPolicy.spsIndicatesInterlaced( + extradata: Array(UnsafeBufferPointer( + start: extradata, + count: Int(codecpar.pointee.extradata_size))))) + } else { + VideoRoutingPolicy.requiresDeinterlaceWarmup( + codecID: codecpar.pointee.codec_id, + fieldOrder: codecpar.pointee.field_order) + } + } else { + preservedRequiresDeinterlaceWarmup + } + + do { let loadStart = DispatchTime.now() if targetSoftwarePath { @@ -2733,11 +2764,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, @@ -2819,6 +2852,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( diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index c1c46a608..be212c71c 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -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. @@ -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). @@ -5453,6 +5454,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 @@ -5486,6 +5490,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)", @@ -5692,10 +5697,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. @@ -5783,6 +5790,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 ) @@ -8167,6 +8176,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 diff --git a/Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift b/Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift index cc6931fa3..69d774ada 100644 --- a/Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift +++ b/Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift @@ -13,10 +13,24 @@ final class DeinterlaceHardwareWarmup: @unchecked Sendable { DeinterlaceFilter.prewarmHardwarePipeline() ? .ready : .unavailable } - private let task: Task + // 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? init(operation: @escaping Operation) { - task = BlockingWork.detached(priority: .userInitiated) { + self.operation = operation + } + + private func sharedTask() -> Task { + 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", @@ -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 = true + ) async -> Outcome? { + guard mode == .auto, sourceIsInterlaced else { return nil } - return await task.value + return await sharedTask().value } } diff --git a/Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift b/Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift index 1bc70e1a4..3dca71e01 100644 --- a/Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift +++ b/Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift @@ -127,6 +127,30 @@ 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. + 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 + default: + return false + } + } + /// #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. diff --git a/Tests/AetherEngineTests/Issue203SoftwareColdStartTests.swift b/Tests/AetherEngineTests/Issue203SoftwareColdStartTests.swift index a1adc83e0..cb5b35739 100644 --- a/Tests/AetherEngineTests/Issue203SoftwareColdStartTests.swift +++ b/Tests/AetherEngineTests/Issue203SoftwareColdStartTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import AetherEngine +@Suite(.timeLimit(.minutes(1))) struct Issue203SoftwareColdStartTests { private actor Gate { @@ -44,7 +45,7 @@ struct Issue203SoftwareColdStartTests { } } - @Test("construction starts one process warm-up shared by concurrent auto waiters") + @Test("first demand starts one process warm-up shared by concurrent auto waiters") func oneTaskServesConcurrentWaiters() async { let started = Gate() let release = Gate() @@ -56,10 +57,9 @@ struct Issue203SoftwareColdStartTests { return .ready } - await started.wait() - async let first = warmup.waitIfNeeded(for: .auto) async let second = warmup.waitIfNeeded(for: .auto) + await started.wait() await release.open() #expect(await first == .ready) @@ -95,11 +95,10 @@ struct Issue203SoftwareColdStartTests { await release.wait() return .ready } - await started.wait() - let waiter = Task { await warmup.waitIfNeeded(for: .auto) } + await started.wait() waiter.cancel() await release.open() @@ -115,4 +114,23 @@ struct Issue203SoftwareColdStartTests { #expect(await warmup.waitIfNeeded(for: .auto) == .unavailable) } + + @Test("progressive and forced-software requests leave warm-up cold until interlaced auto demand") + func startsOnlyForInterlacedAutoDemand() async { + let counter = Counter() + let warmup = DeinterlaceHardwareWarmup { + counter.increment() + return .ready + } + for _ in 0..<32 { + #expect(await warmup.waitIfNeeded(for: .auto, sourceIsInterlaced: false) == nil) + #expect(await warmup.waitIfNeeded(for: .software, sourceIsInterlaced: true) == nil) + } + #expect(counter.value == 0) + #expect(await warmup.waitIfNeeded(for: .auto, sourceIsInterlaced: true) == .ready) + #expect(counter.value == 1) + #expect(await warmup.waitIfNeeded(for: .auto, sourceIsInterlaced: false) == nil) + #expect(counter.value == 1) + } + } diff --git a/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift b/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift index fe70bba35..a93d4d239 100644 --- a/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift +++ b/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift @@ -61,6 +61,29 @@ struct Issue232InterlaceProbeTests { #expect(InterlaceProbe.refutesDeclaredInterlace(verdict)) } + @Test("software loads and reloads carry the interlace decision and stop clears it", .timeLimit(.minutes(2)), + arguments: [false, true]) + @MainActor + func loadCarriesWarmupDecision(interlaced: Bool) async throws { + let fixture = interlaced ? Self.interlacedBase64 : Self.declaredTTProgressiveBase64 + let bytes = try #require(Data(base64Encoded: fixture, options: .ignoreUnknownCharacters)) + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("deinterlace-demand-\(UUID().uuidString).mp4") + try bytes.write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + let engine = try AetherEngine() + defer { engine.stop() } + var options = LoadOptions() + options.autoplay = false + options.preferredDecodePath = .software + _ = try await engine.load(url: file, options: options) + #expect(engine.lastRequiresDeinterlaceWarmup == interlaced) + try await engine.reloadAtCurrentPosition() + #expect(engine.lastRequiresDeinterlaceWarmup == interlaced) + engine.stop() + #expect(!engine.lastRequiresDeinterlaceWarmup) + } + // MARK: - Probe verdicts @Test("a flagged frame upholds the declaration and stops the sample at once") diff --git a/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift b/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift index 4201ea456..a5476ba74 100644 --- a/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift +++ b/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift @@ -82,6 +82,35 @@ struct VideoRoutingPolicyTests { #expect(calls == 1) } + + @Test("progressive software codecs do not need hardware deinterlace warm-up") + func progressiveCodecsSkipDeinterlaceWarmup() { + for codec in [AV_CODEC_ID_NONE, AV_CODEC_ID_VP8, AV_CODEC_ID_VP9, AV_CODEC_ID_AV1, AV_CODEC_ID_HEVC] { + #expect(!VideoRoutingPolicy.requiresDeinterlaceWarmup(codecID: codec, fieldOrder: AV_FIELD_UNKNOWN)) + } + for codec in [AV_CODEC_ID_H264, AV_CODEC_ID_MPEG4, AV_CODEC_ID_MPEG2VIDEO, AV_CODEC_ID_VC1] { + #expect(!VideoRoutingPolicy.requiresDeinterlaceWarmup(codecID: codec, fieldOrder: AV_FIELD_PROGRESSIVE)) + } + } + + @Test("interlaced and unknown legacy sources preserve hardware deinterlace warm-up") + func interlacedSourcesWarmDeinterlacer() { + for order in VideoRoutingPolicy.interlacedFieldOrders { + for codec in [AV_CODEC_ID_H264, AV_CODEC_ID_MPEG4, AV_CODEC_ID_MPEG2VIDEO, AV_CODEC_ID_VC1, + AV_CODEC_ID_DVVIDEO, AV_CODEC_ID_MPEG1VIDEO, AV_CODEC_ID_H263, AV_CODEC_ID_PRORES] { + #expect(VideoRoutingPolicy.requiresDeinterlaceWarmup(codecID: codec, fieldOrder: order)) + } + } + for codec in [AV_CODEC_ID_MPEG4, AV_CODEC_ID_MPEG2VIDEO, AV_CODEC_ID_VC1] { + #expect(VideoRoutingPolicy.requiresDeinterlaceWarmup(codecID: codec, fieldOrder: AV_FIELD_UNKNOWN)) + } + #expect(!VideoRoutingPolicy.requiresDeinterlaceWarmup(codecID: AV_CODEC_ID_H264, fieldOrder: AV_FIELD_UNKNOWN)) + #expect(VideoRoutingPolicy.requiresDeinterlaceWarmup( + codecID: AV_CODEC_ID_H264, fieldOrder: AV_FIELD_UNKNOWN, spsIndicatesInterlaced: true)) + #expect(!VideoRoutingPolicy.requiresDeinterlaceWarmup( + codecID: AV_CODEC_ID_H264, fieldOrder: AV_FIELD_PROGRESSIVE, spsIndicatesInterlaced: true)) + } + // MARK: - #150 SPS frame_mbs_only fallback for UNKNOWN field order @Test("UNKNOWN field order + SPS frame_mbs_only=0 routes to software") diff --git a/docs/architecture.md b/docs/architecture.md index 994601894..9e9cf5fb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -475,7 +475,7 @@ Sources/AetherEngine/ │ ├── SoftwareVideoDecoder.swift SW path: libavcodec/dav1d → CVPixelBuffer (NV12 / P010), HDR10+ side data │ ├── SubtitleDecoder.swift Sidecar URL one-shot decode (text only); decodes several streams of one container in a single pass (#266) │ ├── VideoDecoderTypes.swift DecodedFrameHandler typealias + VideoDecoderError -│ ├── DeinterlaceHardwareWarmup.swift Process-wide one-shot prewarm of the deinterlace filter's hardware pipeline, awaited only by `.auto` mode, so the first interlaced frame does not pay for it +│ ├── DeinterlaceHardwareWarmup.swift Demand-driven process-wide prewarm of the deinterlace filter's hardware pipeline; only potentially interlaced `.auto` loads start and await it. Progressive sources and forced-software deinterlace skip it. Audio reloads preserve the decision; disc-title switches re-evaluate reopened metadata │ └── PixelAspectPolicy.swift Whether a declared sample aspect ratio deserves to be believed: a ratio-magnitude gate (#177, live TS carrying 1088:1), which admits precise or unreduced ratios such as 759:712, plus a display-aspect gate judged against the frame the SAR applies to, since 2:1 is right on 960x1080 and wrong on 1920x1080 (#290) ├── Demuxer/ │ ├── AVIOProvider.swift Internal seam over a custom-AVIO byte source; AVIOReader and CustomIOReaderBridge both plug into the Demuxer through it, incl. the bounded-seek read deadline and the resolved byte size backing the byte-estimate seek fallback (#112) From e43791a28222ccb91f51641db86088a2c8b90e97 Mon Sep 17 00:00:00 2001 From: JonLuca DeCaro Date: Mon, 26 Oct 2026 07:49:43 -0700 Subject: [PATCH 2/3] fix(video): retain same-title deinterlace probe evidence --- CHANGELOG.md | 3 +- .../AetherEngine/AetherEngine+Loading.swift | 29 ++++-------- Sources/AetherEngine/AetherEngine.swift | 6 +-- .../Decoder/DeinterlaceHardwareWarmup.swift | 2 +- .../Decoder/VideoRoutingPolicy.swift | 12 +++++ .../Issue203SoftwareColdStartTests.swift | 12 ++--- .../Issue232InterlaceProbeTests.swift | 46 +++++++++++++++++++ .../VideoRoutingPolicyTests.swift | 1 - docs/architecture.md | 2 +- 9 files changed, 79 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d07a6c5d..ed4002af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,7 +170,8 @@ the public-API contract. - **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, including audio/title reloads. + 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. - **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 diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index cc2623069..8458e3bff 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -2496,7 +2496,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`. @@ -2724,33 +2725,21 @@ extension AetherEngine { } } - let reopenedRequiresDeinterlaceWarmup: Bool = if discTitleIDOverride != nil, + // 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. + let reopenedRequiresDeinterlaceWarmup: Bool = if titleToReopen != outgoingDiscTitleID, let pre = customPreopened, let stream = pre.stream(at: pre.videoStreamIndex), let codecpar = stream.pointee.codecpar { - if codecpar.pointee.codec_id == AV_CODEC_ID_H264, - codecpar.pointee.field_order == AV_FIELD_UNKNOWN, - let extradata = codecpar.pointee.extradata, - codecpar.pointee.extradata_size > 0 - { - VideoRoutingPolicy.requiresDeinterlaceWarmup( - codecID: codecpar.pointee.codec_id, - fieldOrder: codecpar.pointee.field_order, - spsIndicatesInterlaced: VideoRoutingPolicy.spsIndicatesInterlaced( - extradata: Array(UnsafeBufferPointer( - start: extradata, - count: Int(codecpar.pointee.extradata_size))))) - } else { - VideoRoutingPolicy.requiresDeinterlaceWarmup( - codecID: codecpar.pointee.codec_id, - fieldOrder: codecpar.pointee.field_order) - } + 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 { diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index be212c71c..830a0d8e7 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -5309,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; " diff --git a/Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift b/Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift index 69d774ada..292367f61 100644 --- a/Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift +++ b/Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift @@ -53,7 +53,7 @@ final class DeinterlaceHardwareWarmup: @unchecked Sendable { func waitIfNeeded( for mode: DeinterlaceMode, - sourceIsInterlaced: Bool = true + sourceIsInterlaced: Bool ) async -> Outcome? { guard mode == .auto, sourceIsInterlaced else { return nil diff --git a/Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift b/Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift index 3dca71e01..476f1c144 100644 --- a/Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift +++ b/Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift @@ -137,6 +137,8 @@ enum VideoRoutingPolicy { ) -> 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: @@ -151,6 +153,16 @@ enum VideoRoutingPolicy { } } + /// 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) -> 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. diff --git a/Tests/AetherEngineTests/Issue203SoftwareColdStartTests.swift b/Tests/AetherEngineTests/Issue203SoftwareColdStartTests.swift index cb5b35739..74173ec10 100644 --- a/Tests/AetherEngineTests/Issue203SoftwareColdStartTests.swift +++ b/Tests/AetherEngineTests/Issue203SoftwareColdStartTests.swift @@ -57,8 +57,8 @@ struct Issue203SoftwareColdStartTests { return .ready } - async let first = warmup.waitIfNeeded(for: .auto) - async let second = warmup.waitIfNeeded(for: .auto) + async let first = warmup.waitIfNeeded(for: .auto, sourceIsInterlaced: true) + async let second = warmup.waitIfNeeded(for: .auto, sourceIsInterlaced: true) await started.wait() await release.open() @@ -76,7 +76,7 @@ struct Issue203SoftwareColdStartTests { } let started = DispatchTime.now() - let result = await warmup.waitIfNeeded(for: .software) + let result = await warmup.waitIfNeeded(for: .software, sourceIsInterlaced: true) let elapsed = Double( DispatchTime.now().uptimeNanoseconds - started.uptimeNanoseconds ) / 1_000_000_000 @@ -96,14 +96,14 @@ struct Issue203SoftwareColdStartTests { return .ready } let waiter = Task { - await warmup.waitIfNeeded(for: .auto) + await warmup.waitIfNeeded(for: .auto, sourceIsInterlaced: true) } await started.wait() waiter.cancel() await release.open() #expect(await waiter.value == .ready) - #expect(await warmup.waitIfNeeded(for: .auto) == .ready) + #expect(await warmup.waitIfNeeded(for: .auto, sourceIsInterlaced: true) == .ready) } @Test("hardware unavailability completes the gate without throwing") @@ -112,7 +112,7 @@ struct Issue203SoftwareColdStartTests { .unavailable } - #expect(await warmup.waitIfNeeded(for: .auto) == .unavailable) + #expect(await warmup.waitIfNeeded(for: .auto, sourceIsInterlaced: true) == .unavailable) } @Test("progressive and forced-software requests leave warm-up cold until interlaced auto demand") diff --git a/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift b/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift index a93d4d239..45e9c00b8 100644 --- a/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift +++ b/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift @@ -84,6 +84,52 @@ struct Issue232InterlaceProbeTests { #expect(!engine.lastRequiresDeinterlaceWarmup) } + @Test("custom-source reload preserves a same-title progressive verdict and rechecks a different title", + .timeLimit(.minutes(2)), arguments: [false, true]) + @MainActor + func customReloadUsesTitleIdentity(changesTitle: Bool) async throws { + let bytes = try #require(Data(base64Encoded: Self.declaredTTProgressiveBase64, + options: .ignoreUnknownCharacters)) + let engine = try AetherEngine() + defer { engine.stop() } + var options = LoadOptions() + options.autoplay = false + options.preferredDecodePath = .software + _ = try await engine.load(source: .custom(DataIOReader(data: bytes), formatHint: "mp4"), + options: options) + #expect(!engine.lastRequiresDeinterlaceWarmup, "decoded frames refute the TT declaration") + + // Exercise the real custom-reader reopen without a large disc image. The title identity is + // the only extra state a disc contributes to this decision; the reopened codecpar still + // declares TT, so recomputing it wrongly loses the same title's verified progressive result. + engine.activeDiscTitleID = 11 + if changesTitle { + let url = try #require(engine.loadedURL) + let error = await engine.reloadWithAudioOverride( + url: url, audioStreamIndex: nil, expectedGeneration: engine.loadGeneration, + discTitleIDOverride: 12) + #expect(error == nil) + } else { + try await engine.reloadAtCurrentPosition() + } + #expect(engine.lastRequiresDeinterlaceWarmup == changesTitle) + } + + @Test("the shared SPS fallback only breaks an unknown H.264 field-order tie") + func codecParametersGateSPSTieBreaker() throws { + let demuxer = try Self.openFixture(Self.interlacedBase64) + defer { demuxer.close() } + let stream = try #require(demuxer.stream(at: demuxer.videoStreamIndex)) + let parameters = try #require(stream.pointee.codecpar) + parameters.pointee.field_order = AV_FIELD_UNKNOWN + #expect(VideoRoutingPolicy.spsIndicatesInterlaced(codecParameters: parameters)) + parameters.pointee.field_order = AV_FIELD_PROGRESSIVE + #expect(!VideoRoutingPolicy.spsIndicatesInterlaced(codecParameters: parameters)) + parameters.pointee.field_order = AV_FIELD_UNKNOWN + parameters.pointee.codec_id = AV_CODEC_ID_VP9 + #expect(!VideoRoutingPolicy.spsIndicatesInterlaced(codecParameters: parameters)) + } + // MARK: - Probe verdicts @Test("a flagged frame upholds the declaration and stops the sample at once") diff --git a/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift b/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift index a5476ba74..ddb6277e9 100644 --- a/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift +++ b/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift @@ -82,7 +82,6 @@ struct VideoRoutingPolicyTests { #expect(calls == 1) } - @Test("progressive software codecs do not need hardware deinterlace warm-up") func progressiveCodecsSkipDeinterlaceWarmup() { for codec in [AV_CODEC_ID_NONE, AV_CODEC_ID_VP8, AV_CODEC_ID_VP9, AV_CODEC_ID_AV1, AV_CODEC_ID_HEVC] { diff --git a/docs/architecture.md b/docs/architecture.md index 9e9cf5fb2..c9c70f91b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -475,7 +475,7 @@ Sources/AetherEngine/ │ ├── SoftwareVideoDecoder.swift SW path: libavcodec/dav1d → CVPixelBuffer (NV12 / P010), HDR10+ side data │ ├── SubtitleDecoder.swift Sidecar URL one-shot decode (text only); decodes several streams of one container in a single pass (#266) │ ├── VideoDecoderTypes.swift DecodedFrameHandler typealias + VideoDecoderError -│ ├── DeinterlaceHardwareWarmup.swift Demand-driven process-wide prewarm of the deinterlace filter's hardware pipeline; only potentially interlaced `.auto` loads start and await it. Progressive sources and forced-software deinterlace skip it. Audio reloads preserve the decision; disc-title switches re-evaluate reopened metadata +│ ├── DeinterlaceHardwareWarmup.swift Demand-driven process-wide prewarm of the deinterlace filter's hardware pipeline; only potentially interlaced `.auto` loads start and await it. Progressive sources and forced-software deinterlace skip it. Same-title reloads preserve even a decoded progressive verdict; actual disc-title changes re-evaluate reopened metadata. A source declared progressive that later supplies interlaced frames still deinterlaces on demand, paying the cold graph build at that first frame │ └── PixelAspectPolicy.swift Whether a declared sample aspect ratio deserves to be believed: a ratio-magnitude gate (#177, live TS carrying 1088:1), which admits precise or unreduced ratios such as 759:712, plus a display-aspect gate judged against the frame the SAR applies to, since 2:1 is right on 960x1080 and wrong on 1920x1080 (#290) ├── Demuxer/ │ ├── AVIOProvider.swift Internal seam over a custom-AVIO byte source; AVIOReader and CustomIOReaderBridge both plug into the Demuxer through it, incl. the bounded-seek read deadline and the resolved byte size backing the byte-estimate seek fallback (#112) From f16ca7f5ebb4b69c33dda8a0761c3fc934c89817 Mon Sep 17 00:00:00 2001 From: JonLuca DeCaro Date: Mon, 26 Oct 2026 07:53:37 -0700 Subject: [PATCH 3/3] fix(video): preserve warmup evidence across background teardown --- CHANGELOG.md | 1 + .../AetherEngine/AetherEngine+Loading.swift | 9 +++- Sources/AetherEngine/AetherEngine.swift | 3 +- .../BackgroundTeardownSelection.swift | 6 ++- .../Issue232InterlaceProbeTests.swift | 50 +++++++++++++++++++ ...e357BackgroundTeardownSelectionTests.swift | 4 ++ docs/architecture.md | 2 +- 7 files changed, 70 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4002af8..40abd941d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,7 @@ the public-API contract. 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 diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 8458e3bff..38024c0cd 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -2465,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. @@ -2576,7 +2577,8 @@ extension AetherEngine { } // Preserve codec so the decoder label can be reconstructed without re-probing. let preservedVideoCodec = lastDetectedVideoCodec - let preservedRequiresDeinterlaceWarmup = lastRequiresDeinterlaceWarmup + 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. @@ -2727,7 +2729,10 @@ 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. - let reopenedRequiresDeinterlaceWarmup: Bool = if titleToReopen != outgoingDiscTitleID, + // 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 diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 830a0d8e7..a81b9549f 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -6041,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:)` diff --git a/Sources/AetherEngine/BackgroundTeardownSelection.swift b/Sources/AetherEngine/BackgroundTeardownSelection.swift index 314e54ffb..b517cb79d 100644 --- a/Sources/AetherEngine/BackgroundTeardownSelection.swift +++ b/Sources/AetherEngine/BackgroundTeardownSelection.swift @@ -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 { @@ -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 ) } diff --git a/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift b/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift index 45e9c00b8..62cecf63b 100644 --- a/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift +++ b/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift @@ -115,6 +115,56 @@ struct Issue232InterlaceProbeTests { #expect(engine.lastRequiresDeinterlaceWarmup == changesTitle) } + @Test("custom-source background reload retains interlace evidence through repeated teardown", + .timeLimit(.minutes(2)), arguments: [false, true], [false, true]) + @MainActor + func customBackgroundReloadRetainsWarmupDecision(interlaced: Bool, hasDiscTitle: Bool) async throws { + let fixture = interlaced ? Self.interlacedBase64 : Self.declaredTTProgressiveBase64 + let bytes = try #require(Data(base64Encoded: fixture, options: .ignoreUnknownCharacters)) + let engine = try AetherEngine() + defer { engine.stop() } + var options = LoadOptions() + options.autoplay = false + options.preferredDecodePath = .software + _ = try await engine.load(source: .custom(DataIOReader(data: bytes), formatHint: "mp4"), + options: options) + #expect(engine.lastRequiresDeinterlaceWarmup == interlaced) + if hasDiscTitle { engine.activeDiscTitleID = 11 } + + // This is the production background teardown on every platform. Its second invocation + // models a re-armed grace window while the live session has already been cleared. + engine.releaseVideoPipelineForBackground() + engine.releaseVideoPipelineForBackground() + #expect(!engine.lastRequiresDeinterlaceWarmup) + #expect(engine.activeDiscTitleID == nil) + try await engine.reloadAtCurrentPosition() + #expect(engine.lastRequiresDeinterlaceWarmup == interlaced) + #expect(engine.backgroundTeardownSelection == nil) + } + + @Test("a fresh custom load discards the parked source's warm-up decision", + .timeLimit(.minutes(2)), arguments: [false, true]) + @MainActor + func freshCustomLoadDiscardsParkedWarmupDecision(initiallyInterlaced: Bool) async throws { + let engine = try AetherEngine() + defer { engine.stop() } + var options = LoadOptions() + options.autoplay = false + options.preferredDecodePath = .software + for interlaced in [initiallyInterlaced, !initiallyInterlaced] { + let fixture = interlaced ? Self.interlacedBase64 : Self.declaredTTProgressiveBase64 + let bytes = try #require(Data(base64Encoded: fixture, options: .ignoreUnknownCharacters)) + _ = try await engine.load(source: .custom(DataIOReader(data: bytes), formatHint: "mp4"), + options: options) + #expect(engine.lastRequiresDeinterlaceWarmup == interlaced) + #expect(engine.backgroundTeardownSelection == nil) + if interlaced == initiallyInterlaced { + engine.releaseVideoPipelineForBackground() + #expect(engine.backgroundTeardownSelection != nil) + } + } + } + @Test("the shared SPS fallback only breaks an unknown H.264 field-order tie") func codecParametersGateSPSTieBreaker() throws { let demuxer = try Self.openFixture(Self.interlacedBase64) diff --git a/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift b/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift index 535a754c5..778234061 100644 --- a/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift +++ b/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift @@ -163,12 +163,16 @@ struct Issue357BackgroundTeardownSelectionTests { let engine = try AetherEngine() let track = engine.addExternalSubtitleTrack(makeTrack("gone")) engine.selectSubtitleTrack(index: track.id) + engine.playbackBackend = .software + engine.lastRequiresDeinterlaceWarmup = true backgroundTeardown(engine) + #expect(engine.backgroundTeardownSelection?.requiresDeinterlaceWarmup == true) engine.stop() let selection = engine.consumeReloadSelection() #expect(selection.subtitles.activeSubtitleTrackIndex == nil) #expect(selection.audioTrackIndex == nil) + #expect(selection.requiresDeinterlaceWarmup == nil) } // MARK: - The host the teardown keeps (Sodalite#149) diff --git a/docs/architecture.md b/docs/architecture.md index c9c70f91b..c9cf29e12 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -475,7 +475,7 @@ Sources/AetherEngine/ │ ├── SoftwareVideoDecoder.swift SW path: libavcodec/dav1d → CVPixelBuffer (NV12 / P010), HDR10+ side data │ ├── SubtitleDecoder.swift Sidecar URL one-shot decode (text only); decodes several streams of one container in a single pass (#266) │ ├── VideoDecoderTypes.swift DecodedFrameHandler typealias + VideoDecoderError -│ ├── DeinterlaceHardwareWarmup.swift Demand-driven process-wide prewarm of the deinterlace filter's hardware pipeline; only potentially interlaced `.auto` loads start and await it. Progressive sources and forced-software deinterlace skip it. Same-title reloads preserve even a decoded progressive verdict; actual disc-title changes re-evaluate reopened metadata. A source declared progressive that later supplies interlaced frames still deinterlaces on demand, paying the cold graph build at that first frame +│ ├── DeinterlaceHardwareWarmup.swift Demand-driven process-wide prewarm of the deinterlace filter's hardware pipeline; only potentially interlaced `.auto` loads start and await it. Progressive sources and forced-software deinterlace skip it. Same-title reloads preserve even a decoded progressive verdict; the background selection snapshot retains that decision for a custom-reader reopen, while URL reloads probe afresh. Actual disc-title changes re-evaluate reopened metadata. A source declared progressive that later supplies interlaced frames still deinterlaces on demand, paying the cold graph build at that first frame │ └── PixelAspectPolicy.swift Whether a declared sample aspect ratio deserves to be believed: a ratio-magnitude gate (#177, live TS carrying 1088:1), which admits precise or unreduced ratios such as 759:712, plus a display-aspect gate judged against the frame the SAR applies to, since 2:1 is right on 960x1080 and wrong on 1920x1080 (#290) ├── Demuxer/ │ ├── AVIOProvider.swift Internal seam over a custom-AVIO byte source; AVIOReader and CustomIOReaderBridge both plug into the Demuxer through it, incl. the bounded-seek read deadline and the resolved byte size backing the byte-estimate seek fallback (#112)