Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion Sources/AetherEngine/Decoder/SoftwareVideoDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions Tests/AetherEngineTests/FrameDecodeThreadBudgetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
25 changes: 15 additions & 10 deletions Tests/AetherEngineTests/Issue220SoftwareDecoderDrainTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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<AVPacket>? = pkt
defer { trackedPacketFree(&ownedPacket) }
if pkt.pointee.stream_index == videoIndex {
packets += 1
decoder.decode(packet: pkt)
}
var p: UnsafeMutablePointer<AVPacket>? = 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 {
Expand Down
Loading