diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a8cc03b..b6056f7d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ the public-API contract. ### Fixed +- The software video decoder no longer runs more than 16 frame threads. It used one per core, and each frame thread holds back one decoded frame, so a 32-core Mac waited for 31 frames before showing the first one after a load or seek (about 3 s at 10 fps). FFmpeg also warns above 16 threads. Hosts with 16 or fewer cores keep their current thread count. - A paused video no longer starts playing by itself. When the player item died while paused (`failedToPlayToEndTime`), the recovery reload bypassed the pause guard and called `play()` on the fresh item. The reload now keeps a pause made before the item died, whether it came through the engine, AVKit, Control Center or PiP, and mounts the item paused at the same position. - A dead item's recovery no longer restarts the title from where the session was first opened. When AVPlayer refused the recovery item's master (`-11868`), the media fallback reloaded at the first mount's start position, so a title opened from its beginning restarted at 0:00. The fallback now reloads where the refused item was placed. Upstream #621. - The media fallback no longer starts a paused title. When the recovery item was refused, the fallback called `play()` unconditionally, so a title paused behind the tvOS screensaver started itself. It now plays only when the refused item was playing, or was told to play, and the viewer had not paused it. A Play or Pause from AVKit, Control Center or PiP counts as well as one through the engine. diff --git a/Sources/AetherEngine/Decoder/SoftwareVideoDecoder.swift b/Sources/AetherEngine/Decoder/SoftwareVideoDecoder.swift index 37c421432..9529fa8f7 100644 --- a/Sources/AetherEngine/Decoder/SoftwareVideoDecoder.swift +++ b/Sources/AetherEngine/Decoder/SoftwareVideoDecoder.swift @@ -82,6 +82,22 @@ final class SoftwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { /// still extractor is the only caller, everything on a playback path wants the parallel default. var decodesSingleThreaded = false + /// Cores the playback thread budget is sized from. Set before `open`; tests pin it to check the + /// cap a many-core Mac gets. + var activeProcessorCount = ProcessInfo.processInfo.activeProcessorCount + + /// The `thread_count` libavcodec opened with. Written once in `open`, like the other open-time + /// fields. Frame threading holds back `threadCount - 1` decoded frames until flush. + private(set) var threadCount = 0 + + /// Frame threads for a playback decode. Each frame thread delays output by one frame, so one + /// per core on a 32-core Mac held 31 frames back after every load and seek. 16 is FFmpeg's own + /// auto-thread ceiling (`MAX_AUTO_THREADS`); above it libavcodec warns the count is not + /// recommended. Apple TV, iPhone and iPad have fewer cores and are unaffected. + static func playbackThreadCount(activeProcessorCount: Int) -> Int { + return max(1, min(16, activeProcessorCount)) + } + /// AE#499: what the container declared about colour, captured at `open` before a single frame /// exists. A decoded frame carries the VUI alone, and a remux whose VUI is empty would otherwise /// reach `attachColorSpace` as an untagged picture, so an HDR10 file decoded in software lost its @@ -163,7 +179,8 @@ final class SoftwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { ctx.pointee.thread_count = 1 ctx.pointee.thread_type = 0 } else { - ctx.pointee.thread_count = Int32(ProcessInfo.processInfo.activeProcessorCount) + ctx.pointee.thread_count = Int32(Self.playbackThreadCount( + activeProcessorCount: activeProcessorCount)) ctx.pointee.thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE } @@ -176,6 +193,7 @@ final class SoftwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { throw VideoDecoderError.sessionCreationFailed(status: -2) } av_dict_free(&opts) + threadCount = Int(ctx.pointee.thread_count) containerColor = ColorDescription(codecpar: codecpar) let bitsPerSample = codecpar.pointee.bits_per_raw_sample diff --git a/Tests/AetherEngineTests/FrameDecodeThreadBudgetTests.swift b/Tests/AetherEngineTests/FrameDecodeThreadBudgetTests.swift index 35396eac3..422e06b93 100644 --- a/Tests/AetherEngineTests/FrameDecodeThreadBudgetTests.swift +++ b/Tests/AetherEngineTests/FrameDecodeThreadBudgetTests.swift @@ -20,4 +20,14 @@ struct FrameDecodeThreadBudgetTests { #expect(FrameDecodeContext.stillExtractionThreadCount(activeProcessorCount: 1) >= 1) #expect(FrameDecodeContext.stillExtractionThreadCount(activeProcessorCount: 0) >= 1) } + + /// Each frame thread delays software playback output by one frame, so one thread per core + /// held 31 frames back after every load and seek on a 32-core Mac. + @Test("software playback thread count stops at FFmpeg's 16-thread ceiling") + func playbackCapsAtSixteen() { + #expect(SoftwareVideoDecoder.playbackThreadCount(activeProcessorCount: 32) == 16) + #expect(SoftwareVideoDecoder.playbackThreadCount(activeProcessorCount: 16) == 16) + #expect(SoftwareVideoDecoder.playbackThreadCount(activeProcessorCount: 6) == 6) + #expect(SoftwareVideoDecoder.playbackThreadCount(activeProcessorCount: 0) == 1) + } } diff --git a/Tests/AetherEngineTests/Issue220SoftwareDecoderDrainTests.swift b/Tests/AetherEngineTests/Issue220SoftwareDecoderDrainTests.swift index cccd6c2f7..0fbb1d279 100644 --- a/Tests/AetherEngineTests/Issue220SoftwareDecoderDrainTests.swift +++ b/Tests/AetherEngineTests/Issue220SoftwareDecoderDrainTests.swift @@ -39,8 +39,11 @@ struct Issue220SoftwareDecoderDrainTests { // MARK: - Real decode /// Regression guard for the send/drain split: 40 IDR+P packets, no B-frames, so the decoder - /// owes a frame per packet minus whatever its own thread pipeline still holds at the end. - @Test("every packet of a progressive fixture still reaches the frame handler") + /// owes a frame per packet minus the `threadCount - 1` that frame threading holds until flush. + /// A drain that stops early or falls further behind leaves the count short. This synchronous + /// feed never makes `avcodec_send_packet` return EAGAIN, so the retry itself is pinned only by + /// the disposition checks above. + @Test("a progressive fixture reaches the frame handler, short only the thread pipeline") func decodesFixtureFrames() throws { let data = try #require(Data(base64Encoded: Self.fixtureBase64, options: .ignoreUnknownCharacters)) @@ -52,24 +55,26 @@ struct Issue220SoftwareDecoderDrainTests { let stream = try #require(demuxer.stream(at: videoIndex)) let counter = FrameCounter() let decoder = SoftwareVideoDecoder() + // A 32-core Mac's budget: the cap must reach `open`, and every host then runs the same + // 16-deep frame pipeline. + decoder.activeProcessorCount = 32 try decoder.open(stream: stream) { _, _, _ in counter.increment() } defer { decoder.close() } + #expect(decoder.threadCount == 16) var packets = 0 - while let pkt = try? demuxer.readPacket() { + while let pkt = try demuxer.readPacket() { + var ownedPacket: UnsafeMutablePointer? = pkt + defer { trackedPacketFree(&ownedPacket) } if pkt.pointee.stream_index == videoIndex { packets += 1 decoder.decode(packet: pkt) } - var p: UnsafeMutablePointer? = pkt - trackedPacketFree(&p) } - #expect(packets == 40) - // Frame threading holds a bounded number of frames back until flush; the guard is that - // the drain runs at all and keeps up, not the exact pipeline depth. - #expect(counter.value > 0) - #expect(counter.value >= packets - 16) + try #require(packets == 40) + #expect(counter.value >= packets - (decoder.threadCount - 1), + "the drain must keep up with every packet the thread pipeline has released") } private final class FrameCounter: @unchecked Sendable {