diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f7a9fb8f..40abd941d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 5a4235159..38024c0cd 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 @@ -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. @@ -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`. @@ -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. @@ -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. @@ -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 { @@ -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, @@ -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( diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index c1c46a608..a81b9549f 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). @@ -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; " @@ -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 @@ -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)", @@ -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. @@ -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 ) @@ -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:)` @@ -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 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/Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift b/Sources/AetherEngine/Decoder/DeinterlaceHardwareWarmup.swift index cc6931fa3..292367f61 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 + ) 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..476f1c144 100644 --- a/Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift +++ b/Sources/AetherEngine/Decoder/VideoRoutingPolicy.swift @@ -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 + 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) -> 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 a1adc83e0..74173ec10 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 } + async let first = warmup.waitIfNeeded(for: .auto, sourceIsInterlaced: true) + async let second = warmup.waitIfNeeded(for: .auto, sourceIsInterlaced: true) await started.wait() - - async let first = warmup.waitIfNeeded(for: .auto) - async let second = warmup.waitIfNeeded(for: .auto) await release.open() #expect(await first == .ready) @@ -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 @@ -95,16 +95,15 @@ struct Issue203SoftwareColdStartTests { await release.wait() return .ready } - await started.wait() - 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") @@ -113,6 +112,25 @@ 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") + 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..62cecf63b 100644 --- a/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift +++ b/Tests/AetherEngineTests/Issue232InterlaceProbeTests.swift @@ -61,6 +61,125 @@ 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) + } + + @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("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) + 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/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/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift b/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift index 4201ea456..ddb6277e9 100644 --- a/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift +++ b/Tests/AetherEngineTests/VideoRoutingPolicyTests.swift @@ -82,6 +82,34 @@ 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..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 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. 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)