From 7cb09e49d41d8b01641e77cb1b64cc352a20c1f4 Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Sun, 20 Sep 2026 23:15:42 +0200 Subject: [PATCH] fix(native): a session AVPlayer refuses is handed to the engine's own decoder (AE#561) Every recovery under the native path answers the same bytes again. The #93 revive reloads the item at the position that died, and the stage-2 chain refills the same segment. Against a transient that is exactly right; against a segment Apple's parser refuses on its merits it is a loop, and the reporter's capture shows it ending the session with the replacement item dying 62 ms after the first. A failure in the CoreMedia domain is now offered to SoftwarePlaybackHost before it is made terminal: the session is rebuilt at its playhead with preferredDecodePath = .software, which decodes with libavcodec (one skipped frame rather than a dead session) and reads the demuxer directly instead of the loopback HLS, so it also steps around a local-server wedge. Once per session, because a second escalation could only repeat the first. The domain is the whole discriminator. A CoreMedia failure is a verdict on the MEDIA, which is the one thing a second decoder can disagree with; a URL-loading failure is a verdict on the SOURCE, which both paths read through the same reader, so escalating one would spend a rebuild to fail the same way seconds later. Whether the software path can serve this source at all is the existing AE#461 decodePathRefusal, so a source it cannot serve costs a refusal and the original failure rather than a second dead session. Measured on a 40 s HEVC fixture whose damaged sample sits at 20 s, against the same file healthy, with AETHER_DISABLE_NAL_SANITIZER restoring the shape the cut in 729ccafd now removes (that switch is read once from the environment and is never set in a shipped configuration): before, damaged plays to 15.87s, -19602, reload dies on the same segment, dead after, damaged -19602 at 15.88s, rebuilt on the software path at 12.00s, plays through the damaged sample to the end of the file after, healthy unchanged, nothing logged, still native shipping config the sample is cut, the session stays native, nothing escalates Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015PM3xUJB6ZQyqnmGK1fp6F --- .../AetherEngine/AetherEngine+Loading.swift | 35 +++++++- .../AetherEngine+SoftwarePathEscalation.swift | 63 +++++++++++++++ Sources/AetherEngine/AetherEngine.swift | 5 ++ .../Native/NativeAVPlayerHost.swift | 37 +++++++++ .../Native/SoftwarePathEscalation.swift | 79 +++++++++++++++++++ .../AetherEngine/Video/MP4SegmentMuxer.swift | 6 ++ .../SoftwarePathEscalationTests.swift | 74 +++++++++++++++++ docs/architecture.md | 1 + docs/formats.md | 13 +++ 9 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 Sources/AetherEngine/AetherEngine+SoftwarePathEscalation.swift create mode 100644 Sources/AetherEngine/Native/SoftwarePathEscalation.swift create mode 100644 Tests/AetherEngineTests/SoftwarePathEscalationTests.swift diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index dbca2d52..4b1ffe39 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -1551,8 +1551,19 @@ extension AetherEngine { guard self.itemDeathReviveGate.admit(position: position) else { EngineLog.emit( "[AetherEngine] #93 item death (failedToPlayToEndTime) at " - + "\(String(format: "%.2f", position))s; revive budget exhausted, giving up", + + "\(String(format: "%.2f", position))s; revive budget exhausted", category: .engine) + // AE#561: a frozen position across three reloads is the reload answering the + // same bytes three times. Offer the source to the engine's own decoder before + // the session is left dead. + await self.escalateToSoftwarePath( + SoftwarePathEscalation.Request( + domain: SoftwarePathEscalation.mediaErrorDomain, + code: 0, + message: "item death at a frozen position, revive budget exhausted", + positionSeconds: position.isFinite ? max(0, position) : 0 + ) + ) return } EngineLog.emit( @@ -1574,6 +1585,28 @@ extension AetherEngine { } .store(in: &nativeCancellables) + // AE#561: the last rung. Every recovery above reloads the same item against the same bytes, + // which is no answer to a segment AVPlayer refuses on its merits. The engine's own decoder + // reads the demuxer directly and answers a sample Apple's parser rejects by skipping one + // frame, so it is offered the session before the failure is made terminal. Once per session, + // and only for a verdict on the MEDIA (see SoftwarePathEscalation). + let escalationBudget = softwarePathEscalationBudget + let escalationPreferred = loadedOptions.preferredDecodePath + let escalationRemoteHLS = loadedOptions.nativeRemoteHLS + host.softwarePathAvailability = { + SoftwarePathEscalation.Availability( + alreadyEscalated: escalationBudget.isSpent, + preferredDecodePath: escalationPreferred, + nativeRemoteHLS: escalationRemoteHLS + ) + } + host.$pendingSoftwarePathEscalation + .compactMap { $0 } + .sink { [weak self] request in + Task { @MainActor [weak self] in await self?.escalateToSoftwarePath(request) } + } + .store(in: &nativeCancellables) + // appliesPerFrameHDRDisplayMetadata unconditionally true: DV P5 has no HDR10 base layer, so the per-frame RPU is what AVPlayer's tone-mapper needs on a non-DV panel (DrHurt #4 2026-05-26). Prior servingMasterPlaylist gate broke P5. Apple's default is also true; explicit write surfaces the live value in diagnostics. // forwardBufferDuration default (4 s): deep buffer lets AVPlayer race to the live edge and hit the transcode warm-up gap head-on (-12888); 4 s PACES consumption. Verified: 8 s worsened startup pause (8-10 s vs ~1 s). // Live REJOIN: skip initial seek so AVPlayer picks edge-minus-holdback instead; seek-to-0 against the re-served backlog wedged the reloaded item in waitingToPlay (device repro: tvOS 26, Jellyfin stream.ts). See LiveReloadPolicy. diff --git a/Sources/AetherEngine/AetherEngine+SoftwarePathEscalation.swift b/Sources/AetherEngine/AetherEngine+SoftwarePathEscalation.swift new file mode 100644 index 00000000..1f73cba0 --- /dev/null +++ b/Sources/AetherEngine/AetherEngine+SoftwarePathEscalation.swift @@ -0,0 +1,63 @@ +import Foundation + +/// AE#561: the rung under every native recovery, taken when AVPlayer refuses the media itself. +/// +/// The recoveries above this one all answer the same bytes again: the #93 revive reloads the item at +/// the position that died, and the stage-2 chain refills the same segment. Against a transient that +/// is exactly right. Against a segment Apple's parser refuses on its merits it is a loop, and the +/// reporter's capture shows it ending the session with the replacement item dying 62 ms after the +/// first. `SoftwarePlaybackHost` decodes with libavcodec, which skips such a frame and plays on, and +/// it reads the demuxer directly rather than the loopback HLS, so it steps around a local-server +/// wedge too. +/// +/// The rebuild is `reloadAtCurrentPosition(applying:)`, which keeps the session: same playhead, same +/// subtitle carryover, same external-track registry. Its own `decodePathRefusal` is what decides +/// whether the software path can serve this source at all, so a source it cannot serve costs a +/// refusal here rather than a second dead session. +extension AetherEngine { + + /// Rebuild this session on the software path, once, because the native one refused the media. + @MainActor + func escalateToSoftwarePath(_ request: SoftwarePathEscalation.Request) async { + guard SoftwarePathEscalation.shouldEscalate( + errorDomain: request.domain, + availability: SoftwarePathEscalation.Availability( + alreadyEscalated: softwarePathEscalationBudget.isSpent, + preferredDecodePath: loadedOptions.preferredDecodePath, + nativeRemoteHLS: loadedOptions.nativeRemoteHLS + ) + ) else { return } + // The host's probe read mount-time options and the #93 rung does not consult it at all, so + // the decision is made again here, against what the session is actually running on. + guard softwarePathEscalationBudget.take() else { return } + + EngineLog.emit( + "[AetherEngine] #561 AVPlayer refused the media (\(request.domain)/\(request.code)) at " + + "\(String(format: "%.2f", request.positionSeconds))s; rebuilding this session on the " + + "software path, which decodes it with libavcodec instead: \(request.message)", + category: .engine + ) + + do { + try await reloadAtCurrentPosition { $0.preferredDecodePath = .software } + EngineLog.emit( + "[AetherEngine] #561 rebuilt on the software path", category: .engine) + } catch { + // The rung is gone and the failure was never surfaced, so it has to be surfaced here or + // the session would sit on a picture that stopped with nothing said. + EngineLog.emit( + "[AetherEngine] #561 the software path cannot serve this session (\(error)); " + + "surfacing the original failure", + category: .engine + ) + publishError( + PlaybackErrorInfo( + kind: .nativeItemFailed, + message: request.message, + underlyingDomain: request.domain.isEmpty ? nil : request.domain, + underlyingCode: request.code == 0 ? nil : request.code + ) + ) + } + } +} diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 9f366a71..7cef9160 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -2192,6 +2192,10 @@ public final class AetherEngine: ObservableObject { /// bounded reload budget. Cancelled on load reset; superseded by newer deaths. var itemDeathConfirmTask: Task? = nil var itemDeathReviveGate = ItemDeathReviveGate(maxAttempts: 3) + /// AE#561: the one rebuild onto the software path this session may spend when AVPlayer refuses + /// the media. Replaced (not reset) on teardown, so a closure held by a dead host cannot spend + /// the next session's. + var softwarePathEscalationBudget = SoftwarePathEscalation.Budget() /// #65 final rung, storm shape: on a frozen live playlist each stage-2 reload replays the tail, /// re-stalls within seconds, and the fresh stall SUPERSEDES the ladder task before its /// post-reload rung can run, so the reload cycle alone would loop forever. This gate persists @@ -3643,6 +3647,7 @@ public final class AetherEngine: ObservableObject { itemDeathConfirmTask = nil itemDeathReviveGate = ItemDeathReviveGate(maxAttempts: 3) stallReloadReviveGate = ItemDeathReviveGate(maxAttempts: 2) + softwarePathEscalationBudget = SoftwarePathEscalation.Budget() masterFallbackUsed = false nativeSubtitleReanchorTask?.cancel() nativeSubtitleReanchorTask = nil diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index 40e79d42..cb4bb23b 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -44,6 +44,9 @@ final class NativeAVPlayerHost { /// AE#495: what the AE#495 relay knows about the origin's certificate, when the item this host /// plays is served by one. Set by the engine at mount, read only while classifying a failure. var upstreamTrustRefusal: (@Sendable () -> Int?)? + /// AE#561: what the session can still offer when AVPlayer refuses the media, answered by the + /// engine because the host owns none of it. Set at mount, read only while classifying a failure. + var softwarePathAvailability: (@Sendable () -> SoftwarePathEscalation.Availability?)? /// #50: latched on first .playing; discriminates startup failures (never played) from mid-playback transients. .failed and timeControlStatus KVOs are unsynchronized, so instantaneous status is unreliable. Reset with the item on a reused host. private var hasEverPlayed = false @Published private(set) var didReachEnd: Bool = false @@ -134,6 +137,11 @@ final class NativeAVPlayerHost { /// surfaces the failure. Reset on each load. @Published private(set) var pendingDisplayRejection: DisplayRejection? + /// AE#561: a failure this host would otherwise have made terminal, offered to the engine's own + /// decoder instead. The engine's subscriber rebuilds the session on the software path at the + /// carried position. Reset on each load. + @Published private(set) var pendingSoftwarePathEscalation: SoftwarePathEscalation.Request? + /// AetherEngine#168: dynamic range read back from the item's parsed video-track CMFormatDescription, /// so the probe-free `nativeRemoteHLS` bypass can report the real format instead of the `.sdr` default. /// nil until a video track resolves (or when none does: the audio-only black-screen symptom). The @@ -524,6 +532,7 @@ final class NativeAVPlayerHost { } failure = nil pendingDisplayRejection = nil + pendingSoftwarePathEscalation = nil lastSuppressedStartupFailure = nil isReady = false seekableEnd = 0 @@ -891,6 +900,30 @@ final class NativeAVPlayerHost { /// Discriminates on hasEverPlayed, not instantaneous timeControlStatus: .failed and timeControlStatus KVOs are unsynchronized (426b45c: still published terminal failure at 27.3s while AVPlayer played smoothly). /// Before first .playing: surface promptly (genuine startup failure). After: defer 5s and confirm -- clear if .playing or clock advanced, surface if both stopped. @MainActor + /// AE#561: offer a failure to the engine's own decoder before making it terminal. True when the + /// offer was made, in which case nothing is surfaced here and the engine rebuilds the session. + private func offerToSoftwarePath(_ desc: String, item: AVPlayerItem, position: Double) -> Bool { + let nsError = item.error as NSError? + guard SoftwarePathEscalation.shouldEscalate( + errorDomain: nsError?.domain, + availability: softwarePathAvailability?() + ) else { return false } + let at = position.isFinite ? String(format: "%.2f", position) + "s" : "an unreadable position" + EngineLog.emit( + "[NativeAVPlayerHost] #\(sessionID) #561 AVPlayer refused the media " + + "(\(nsError?.domain ?? "?")/\(nsError?.code ?? 0)) at \(at); handing the session to the " + + "engine's own decoder instead of surfacing: \(desc)", + category: .engine + ) + pendingSoftwarePathEscalation = SoftwarePathEscalation.Request( + domain: nsError?.domain ?? "", + code: nsError?.code ?? 0, + message: desc, + positionSeconds: position.isFinite ? max(0, position) : 0 + ) + return true + } + private func handleItemFailed(_ desc: String, item: AVPlayerItem) { // Ignore a late `.failed` KVO from an item we have already replaced. guard playerItem === item else { return } @@ -939,6 +972,9 @@ final class NativeAVPlayerHost { domain: (item.error as NSError?)?.domain) return } + // AE#561: a startup failure on the media itself (a segment Apple's parser refuses) is + // not the end of the source, only of this consumer's opinion of it. + if offerToSoftwarePath(desc, item: item, position: renderedTime) { return } failure = Self.itemFailureInfo(desc: desc, itemError: item.error, relayRefusalCode: upstreamTrustRefusal?()) return @@ -968,6 +1004,7 @@ final class NativeAVPlayerHost { + "clock=\(String(format: "%.2f", self.renderedTime)))", category: .engine ) + if self.offerToSoftwarePath(desc, item: item, position: self.renderedTime) { return } self.failure = Self.itemFailureInfo(desc: desc, itemError: item.error, relayRefusalCode: self.upstreamTrustRefusal?()) } else { diff --git a/Sources/AetherEngine/Native/SoftwarePathEscalation.swift b/Sources/AetherEngine/Native/SoftwarePathEscalation.swift new file mode 100644 index 00000000..241e7a2c --- /dev/null +++ b/Sources/AetherEngine/Native/SoftwarePathEscalation.swift @@ -0,0 +1,79 @@ +import Foundation + +/// The last rung under a native session AVPlayer will not play: hand the source to the engine's own +/// decoder instead of ending the session (AE#561). +/// +/// Every recovery above this one reloads the SAME item against the SAME bytes: the #93 revive +/// reloads at the position that died, and the stage-2 chain refills the same segment. That is the +/// right answer to a transient, and no answer at all to a segment AVPlayer refuses on its merits, +/// which is what a damaged source produces. The reporter's capture shows the shape exactly: the +/// item dies with `-19602`, the reload lands on the same segment, and the replacement item dies on +/// it 62 ms later. +/// +/// `SoftwarePlaybackHost` decodes with libavcodec, which answers a sample Apple's parser rejects by +/// skipping one frame, and it reads the demuxer directly rather than the loopback HLS the native +/// path is served over, so it also steps around a local-server wedge. One escalation per session, +/// because a second one could only repeat the first. +enum SoftwarePathEscalation { + + /// What the host hands the engine when it would otherwise surface a terminal failure. + struct Request: Equatable, Sendable { + /// The failure that prompted it, carried so the engine's log names the real cause. + let domain: String + let code: Int + let message: String + /// Where the session was, so the rebuild lands where the viewer was watching. + let positionSeconds: Double + } + + /// One session's single escalation, shared with the mount-time closure the host reads. + /// + /// A reference type because the host's probe is `@Sendable` and the latch it has to see lives on + /// the engine. `take()` is what spends it, so two failures arriving together cannot both rebuild. + final class Budget: @unchecked Sendable { + private let lock = NSLock() + private var spent = false + + var isSpent: Bool { + lock.lock(); defer { lock.unlock() } + return spent + } + + /// True when this call took the escalation, false when it was already gone. + func take() -> Bool { + lock.lock(); defer { lock.unlock() } + if spent { return false } + spent = true + return true + } + } + + /// What the session can still offer, answered by the engine because the host owns none of it. + struct Availability: Equatable, Sendable { + /// This session has spent its one escalation already. + let alreadyEscalated: Bool + /// The path the session is running on. + let preferredDecodePath: DecodePath + /// The remote-HLS bypass, where the engine decodes nothing at all. + let nativeRemoteHLS: Bool + } + + /// The domain of a media failure, i.e. AVFoundation could not make sense of what it was served. + static let mediaErrorDomain = "CoreMediaErrorDomain" + + /// Whether a failed native item is worth handing to the engine's own decoder. + /// + /// The domain is the discriminator. A CoreMedia failure is a verdict on the MEDIA, which is the + /// one thing a second decoder can disagree with. A URL-loading failure is a verdict on the + /// SOURCE, which both paths read through the same reader, so escalating one would only spend a + /// rebuild to fail the same way a few seconds later. Nil availability means no engine answered, + /// which is never a reason to swallow a failure. + static func shouldEscalate(errorDomain: String?, availability: Availability?) -> Bool { + guard let availability, !availability.alreadyEscalated else { return false } + // Already there, or the host asked for a path this cannot improve on. + guard availability.preferredDecodePath == .automatic else { return false } + // The bypass has no local muxer and decodes nothing here, so #461 ignores the option anyway. + guard !availability.nativeRemoteHLS else { return false } + return errorDomain == mediaErrorDomain + } +} diff --git a/Sources/AetherEngine/Video/MP4SegmentMuxer.swift b/Sources/AetherEngine/Video/MP4SegmentMuxer.swift index 3321c68a..ec379c85 100644 --- a/Sources/AetherEngine/Video/MP4SegmentMuxer.swift +++ b/Sources/AetherEngine/Video/MP4SegmentMuxer.swift @@ -167,6 +167,11 @@ final class MP4SegmentMuxer { /// length-prefixed at all. Latched at init: it is a property of the configuration record that /// lands in the sample entry, and the AE#561 sanitizer walks every video sample with it. private let videoNALLengthPrefixSize: Int? + /// AE#561 harness switch: the sanitizer removes the only shape that reproduces a segment Apple's + /// parser refuses, so the rung underneath it (the software-path escalation) would have nothing to + /// be measured against. Read once from the environment, never set in a shipped configuration. + static let nalChainSanitizerDisabled = + ProcessInfo.processInfo.environment["AETHER_DISABLE_NAL_SANITIZER"] != nil /// How many video samples the AE#561 sanitizer has had to cut, over this muxer's life. private var truncatedVideoSamples: Int = 0 @@ -616,6 +621,7 @@ final class MP4SegmentMuxer { // is why the file plays elsewhere. Cut the sample at its last complete NAL, which is what // MKVToolNix writes when it remuxes one of these files. if streamIndex == videoOutputStreamIndex, + !Self.nalChainSanitizerDisabled, let lengthPrefixSize = videoNALLengthPrefixSize, let data = packet.pointee.data, packet.pointee.size > 0, diff --git a/Tests/AetherEngineTests/SoftwarePathEscalationTests.swift b/Tests/AetherEngineTests/SoftwarePathEscalationTests.swift new file mode 100644 index 00000000..3a9b6d2a --- /dev/null +++ b/Tests/AetherEngineTests/SoftwarePathEscalationTests.swift @@ -0,0 +1,74 @@ +// The last rung under a native session AVPlayer will not play (AE#561). +// +// Every recovery above it reloads the same item against the same bytes, which is the right answer to +// a transient and a loop against a segment Apple's parser refuses on its merits. This decides when +// the engine's own decoder is offered the session instead of the failure being made terminal. +import Foundation +import Testing +@testable import AetherEngine + +@Suite("Software-path escalation (AE#561)") +struct SoftwarePathEscalationTests { + + private static func availability( + escalated: Bool = false, + path: DecodePath = .automatic, + remoteHLS: Bool = false + ) -> SoftwarePathEscalation.Availability { + SoftwarePathEscalation.Availability( + alreadyEscalated: escalated, preferredDecodePath: path, nativeRemoteHLS: remoteHLS) + } + + @Test("A media failure on a fresh native session is escalated") + func mediaFailureEscalates() { + #expect(SoftwarePathEscalation.shouldEscalate( + errorDomain: "CoreMediaErrorDomain", availability: Self.availability())) + } + + /// The domain is the whole discriminator: a second decoder can disagree about the media, and + /// cannot disagree about a source neither path can read. + @Test("A source failure is not escalated, whatever its code") + func sourceFailureIsNotEscalated() { + #expect(!SoftwarePathEscalation.shouldEscalate( + errorDomain: NSURLErrorDomain, availability: Self.availability())) + #expect(!SoftwarePathEscalation.shouldEscalate( + errorDomain: "AVFoundationErrorDomain", availability: Self.availability())) + #expect(!SoftwarePathEscalation.shouldEscalate( + errorDomain: nil, availability: Self.availability())) + } + + @Test("The session spends its escalation once") + func onlyOnce() { + #expect(!SoftwarePathEscalation.shouldEscalate( + errorDomain: "CoreMediaErrorDomain", availability: Self.availability(escalated: true))) + } + + @Test("A session already on the software path has nowhere to escalate to") + func alreadySoftware() { + #expect(!SoftwarePathEscalation.shouldEscalate( + errorDomain: "CoreMediaErrorDomain", availability: Self.availability(path: .software))) + } + + /// The bypass has no local muxer and the engine decodes nothing on it, so #461 ignores the + /// option there; escalating would spend a rebuild to arrive where it started. + @Test("The remote-HLS bypass is not escalated") + func remoteHLSIsRefused() { + #expect(!SoftwarePathEscalation.shouldEscalate( + errorDomain: "CoreMediaErrorDomain", availability: Self.availability(remoteHLS: true))) + } + + @Test("No answer from the engine is never a reason to swallow a failure") + func noAvailabilityRefuses() { + #expect(!SoftwarePathEscalation.shouldEscalate( + errorDomain: "CoreMediaErrorDomain", availability: nil)) + } + + @Test("The budget is spent by the first taker, not by the second") + func budgetIsTakenOnce() { + let budget = SoftwarePathEscalation.Budget() + #expect(!budget.isSpent) + #expect(budget.take()) + #expect(budget.isSpent) + #expect(!budget.take()) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 0dafc2cf..f0096583 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -419,6 +419,7 @@ Sources/AetherEngine/ │ ├── Issue93ItemDeathRevive.swift Bounded revive budget (`ItemDeathReviveGate`) for items killed by accumulated -12889 media timeouts (`failedToPlayToEndTime`, #93 round 3) │ ├── MasterFallbackDecision.swift Pure master → media playlist fallback decision (#98, #130): maps a master-rejection item failure (-11868 external-SDR, -11848 HDR-on-SDR, -1002 all variants filtered at parse) to a reactive re-serve │ ├── NativeAVPlayerHost.swift Native path: AVPlayer host bound to the loopback HLS-fMP4 URL; awaits real seek landing (deadline-bounded, first resume wins, #129), suppresses stale clock during in-flight seek +│ ├── SoftwarePathEscalation.swift Native path: the last rung. A CoreMedia-domain failure (AVPlayer refusing the MEDIA, not the source) is offered to the engine's own decoder once per session instead of ending it, since every recovery above reloads the same bytes (AE#561) │ ├── RemoteHLSMediaSelection.swift Remote-HLS bypass (#154): pure loopback→bypass reroute decision for non-live m3u8 sources (FFmpeg has no network) + legible AVMediaSelectionGroup → `subtitleTracks` mapping (synthetic ids from 200000) │ ├── SoftwarePlaybackHost.swift SW path: demux loop + decoders + renderer + synchronizer orchestration │ ├── AirPlayPlaylistDecision.swift Pure playlist choice for the wireless-AirPlay loopback rewrite: which of master / media a receiver is handed, kept separate and testable offline like `MasterFallbackDecision` (#86, #227) diff --git a/docs/formats.md b/docs/formats.md index b313039b..6ef09935 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -149,6 +149,19 @@ with no complete unit at all is dropped instead of written empty. The fixture ge `Scripts/nal-overrun-fixture.py` forges the shape into any length-prefixed source by rewriting four bytes, so the healthy original stands as the control arm (AE#561). +Under that sits a last rung for the cases the cut cannot reach, because every recovery +above it answers the same bytes again: the #93 revive reloads the item at the position +that died, and the stage-2 chain refills the same segment, so a segment AVPlayer refuses +on its merits ends the session with the replacement item dying milliseconds after the +first. A failure in the CoreMedia domain is therefore offered to `SoftwarePlaybackHost` +before it is made terminal: the session is rebuilt at its playhead with +`preferredDecodePath = .software`, which decodes with libavcodec (one skipped frame rather +than a dead session) and reads the demuxer directly instead of the loopback HLS. Once per +session, and only for a verdict on the MEDIA: a URL-loading failure is a verdict on the +SOURCE, which both paths read through the same reader. Whether the software path can serve +the source at all is the existing #461 `decodePathRefusal`, so a source it cannot serve +costs a refusal and the original failure, not a second dead session. + ## HDR routing | Source | Wrapper signaling |