diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bfed7755..b5470c69c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,76 @@ the public-API contract. ## [Unreleased] -### Fixed +### Added + +- **`AETHER_FEL_CHROMA` picks the chroma sampling of the composed Dolby Vision frame (`DualLayerDolbyVision`, experimental).** + The composition is defined on the 4:2:0 sites, and 4:2:0 stays the default and the output playback + uses. `AETHER_FEL_CHROMA=422` or `444` steps that result up to 4:2:2 or 4:4:4 by co-sited means (a copy + on a site, the mean of the sites around a sample between them), for an A/B run on a display. 4:4:4 is + experimental: the spec has no 4:4:4 reconstruction, so it only steps the 4:2:0 result up. Neither format + is checked on a TV yet, and both take the pool path (in-place composition is 4:2:0 only). Any other value + keeps 4:2:0. See `docs/dolby-vision-fel.md`. + +### Changed + +- **The `DualLayerDolbyVision` composer writes into the decoded base-layer buffer instead of a separate pool.** + When the base-layer buffer is IOSurface-backed, 10-bit biplanar video range, even-sized and the luma + curve is not MMR, the chroma pass and then the luma pass overwrite the buffer in place, so the + output pool is not allocated (a frame refused for in-place still takes the pool path). A buffer is + never composed twice at once, and a command buffer that fails after it started drops the frame + instead of showing a mix. Measured on the Mac (4K, 200 s, 7 seeks, two runs each): our process + falls by about 300 MB, the decoder service gains about 170 MB, a net fall of about 150 MB. The + composition is bit-identical to the pool path on the Mac (unit tests and a real-decoder spot check; + not yet checked on a TV). `AETHER_FEL_INPLACE=0` turns it off for an A/B run. + See `docs/dolby-vision-fel.md`. + +- **The dual-layer route says in the log that an E-AC-3 track is decoded to PCM, and keeps session totals of the deadline releases (`DualLayerDolbyVision`).** + An E-AC-3 track played with a Profile 7 dual-layer video is decoded to PCM, which loses the Atmos + objects, with nothing in the log to say so; the producer now logs that once per session. The + dual-layer decoder also logs `totals:` (composed frames and the deadline, held-wall and backstop + releases, session-wide) at info level, at most every 10 s and only when a count moved, so a run's + log carries the numbers without a debug build. + +### Fixed + +- **A Dolby Vision FEL base frame whose enhancement layer does not arrive is released by its display deadline (`DualLayerDolbyVision`).** + A waiting base frame was given up on only after three newer base frames had arrived, so a damaged + or missing enhancement-layer stretch held the picture back and showed up as bursts of dropped + frames. The decoder now reads the render clock and releases the frame as its base layer once its + presentation time is within a margin of `maxFramesInFlight` frame intervals (at least 100 ms), + after a short grace (one frame interval, 15 to 50 ms) for an enhancement layer that is only a + moment late. The sweep re-arms on every arrival; a paused or prerolling clock holds the frames + (bounded at 2 s), and the old count rule stays as a backstop. On an A12 Apple TV with the + enhancement layer removed for 60 s of a clip the run dropped 1 frame, against 45 and 71 before (measured on an earlier debug build). + +- **A Dolby Vision FEL frame shown as its base layer carries the RPU that keeps its mapping (`DualLayerDolbyVision`).** + A frame released by its display deadline, bypassed for a missing enhancement layer or RPU, or left + as its base layer by a failed composition went out with the composed-frame RPU, whose curves are + set to no-op because the composer is meant to have reshaped the picture. Nothing had, so the + display showed an unreshaped frame between composed ones. Those frames now carry the Profile 8.1 + RPU with the source's mapping and display metadata. libdovi's mode 2 sets the curves to no-op only + when the RPU's own `el_type` is FEL, and that comes from the NLQ block, which is absent when + `disable_residual_flag` is set, so a composed frame's RPU now also has its mapping removed + whatever the `el_type`. The remux route is unchanged. The cost is a second libdovi parse and + conversion per RPU on the dual-layer route and one more NAL per side-data entry; when the + mapping-keeping form cannot be built the frame carries the composed form, as before. Keeping the + mapping on a raw base layer follows the fix for a missing enhancement layer in TheFireKahuna's AetherEngine fork (thanks + @TheFireKahuna); only its RPU half is taken here, the frame is still shown as its base layer. + +- **The `DualLayerDolbyVision` composer covers the last chroma column of odd sizes, refuses a zero NLQ ceiling and pairs a passed enhancement slot by access unit.** + The chroma planes were sized with a floor division in the texture binds, the dispatch and the + kernel grid, so the last chroma column and row of an odd luma size was never written; they now + round up. + A `vdr_in_max` that shifts down to 0 now drops the residual like an out-of-range one, and the + prediction is clamped to the VDR range in float before the integer conversion, since an MMR result + far outside it overflowed int. When the base layer has only ever stepped forward, an enhancement + slot at or below the latest base picture that is not in the base time table pairs by access unit + like an unknown slot instead of taking an extrapolated time that belongs to no base frame. The + `el_type` probe rewinds its source with the same 5 s bounded seek the + interlace probe uses, and a stalled rewind leaves the type unknown, so the route is not taken. The + arrival-order path counts the frames its seek threshold discards (`skip=` in the cadence line, + separate from `lost=`) and logs the first one once per seek. Without the trait only the counter and + its log line exist, and nothing reaches them. - **A 24.000 fps source is programmed as 24 Hz, not 23.976 Hz (AE#884, thanks @akhanwav).** `FrameRateSnap` sent the whole 23.5 to 24.05 window to 23.976, so Match Frame Rate ran 24p content one frame in 1001 fast and the diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 792564d47..93afb651b 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -5413,7 +5413,16 @@ public final class AetherEngine: ObservableObject { let vIdx = probe.videoStreamIndex elType = await BlockingWork.detached(priority: .userInitiated) { [probe] in let result = DolbyVisionRecordAudit.probeEnhancementLayerType(demuxer: probe, streamIndex: vIdx) - probe.seek(to: 0) + // The session reuses this demuxer, so a type read from a position that cannot be + // restored is worth nothing: a seek that stalls leaves the type unknown, and the + // route is not taken. + guard probe.seekBounded(to: 0, timeout: Self.probeRewindSeekTimeout) else { + EngineLog.emit( + "[AetherEngine] el_type probe could not rewind the source within " + + "\(Int(Self.probeRewindSeekTimeout)) s; enhancement layer type unknown", + category: .engine) + return nil + } return result }.value if loadGeneration != gen { diff --git a/Sources/AetherEngine/Decoder/DualLayerPictureAlignment.swift b/Sources/AetherEngine/Decoder/DualLayerPictureAlignment.swift index 084bb09df..a9d4a3fb3 100644 --- a/Sources/AetherEngine/Decoder/DualLayerPictureAlignment.swift +++ b/Sources/AetherEngine/Decoder/DualLayerPictureAlignment.swift @@ -78,6 +78,9 @@ struct DualLayerPictureAlignment { /// First base-layer slot seen since the reset: with the latest it gives the time per slot. private var origin: (slot: Int, pts: Int64)? private var latest: (slot: Int, pts: Int64)? + /// Whether the base layer has decoded a picture below a slot it had already seen (a B-frame), so a + /// slot below `latest` that has no time yet can still be to come. Cleared by `reset`. + private var baseReordered = false /// Units whose enhancement-layer picture was moved to another frame's time. private(set) var reorderedUnits = 0 @@ -97,6 +100,7 @@ struct DualLayerPictureAlignment { baseTime.removeAll() origin = nil latest = nil + baseReordered = false } /// Reads both layers' pictures from one access unit (base-layer NALs, and enhancement-layer NALs @@ -131,6 +135,7 @@ struct DualLayerPictureAlignment { for s in baseTime.keys where s < unit - 32 { baseTime.removeValue(forKey: s) } } if origin == nil { origin = (blSlot, pts) } + if let latest, blSlot < latest.slot { baseReordered = true } latest = (blSlot, pts) } @@ -141,8 +146,14 @@ struct DualLayerPictureAlignment { reorderedUnits += 1 return known } - // That base-layer picture is still to come: extrapolate from the time per slot. - guard let origin, let latest, latest.slot != origin.slot, + // A slot beyond the latest base picture is still to come: extrapolate from the time per slot. + // A slot at or below it that is not in `baseTime` is still to come only when the base layer + // itself reorders (a B-frame decoded after a higher slot has been seen). With a base layer + // that has only ever stepped forward, such a slot was pruned or its base picture carried no + // POC: an extrapolated time would belong to no base frame, so it pairs by access unit like an + // unknown slot. A base layer is taken to reorder from its first B-frame on, so the unit before + // that one is paired by access unit as well. + guard let origin, let latest, elSlot > latest.slot || baseReordered, latest.slot != origin.slot, let result = Self.extrapolate(origin: origin, latest: latest, slot: elSlot) else { lastEnhancementPlaced = false; return pts } diff --git a/Sources/AetherEngine/Decoder/DualLayerVideoDecoder.swift b/Sources/AetherEngine/Decoder/DualLayerVideoDecoder.swift index 69deba181..3d6a77981 100644 --- a/Sources/AetherEngine/Decoder/DualLayerVideoDecoder.swift +++ b/Sources/AetherEngine/Decoder/DualLayerVideoDecoder.swift @@ -17,10 +17,14 @@ import AetherLibavutil /// `OrderedDelivery.emit` runs on the delivery queue and does not reach /// into the decoder's frame state (`dmRpuCache`, the queues) itself. `HardwareVideoDecoder` passes /// `hdr10Data: nil` today (latent), so that field is unused in practice, with -/// room for params if they migrate here too. +/// room for params if they migrate here too. `baseLayerNAL` is the same RPU with its mapping kept: the +/// frame carries it instead of `dmNAL` when it goes out as its raw base layer (released without its +/// enhancement layer, or a composition that left the base layer intact), because nothing reshaped the +/// picture and the display side has to. private struct FrameSideData: Sendable { let hdr10Data: Data? let dmNAL: [UInt8]? + let baseLayerNAL: [UInt8]? } /// What `DualLayerVideoDecoder` needs from its composer. @@ -53,9 +57,33 @@ protocol FelComposing: Sendable { func takePeakSlotsInUse() -> Int var allocatedPoolBuffers: Int { get } var meanGPUTimeMs: Double? { get } + /// Whether this frame's base-layer buffer can be the compose target (written in place). + func canComposeInPlace(baseBuffer: CVPixelBuffer, params: DoviMetalParams) -> Bool + /// Composes into the base-layer buffer itself; same slot rules as `composeAsyncWithAcquiredSlot`. + func composeInPlaceAsyncWithAcquiredSlot( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + completion: @escaping @Sendable (DoviFelComposer.InPlaceResult) -> Void + ) + /// Frames composed in place so far, for the 1 Hz summary. + var inPlaceComposedFrames: Int { get } } extension FelComposing { + func canComposeInPlace(baseBuffer: CVPixelBuffer, params: DoviMetalParams) -> Bool { false } + func composeInPlaceAsyncWithAcquiredSlot( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + completion: @escaping @Sendable (DoviFelComposer.InPlaceResult) -> Void + ) { + releaseSlot() + completion(.baseIntact) + } + var inPlaceComposedFrames: Int { 0 } func releaseOutputPool() {} func flushTextureCache() {} func takePeakSlotsInUse() -> Int { 0 } @@ -331,17 +359,95 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { /// Restamps each enhancement-layer packet with the time of the base-layer picture of the same POC, /// so pairing by time pairs by picture order even when the layers' GOPs differ. Guarded by `lock`. private var pictureAlignment = DualLayerVideoDecoder.makeAlignment() - private var blFrameQueue: [Int64: (buffer: CVPixelBuffer, pts: CMTime, ticket: UInt64, seq: UInt64, side: FrameSideData)] = [:] + /// `deadline` is the earliest presentation time (source seconds) among this frame and every base + /// frame that took a later ticket: delivery is ordered by ticket, so none of those can leave before + /// this one does. `arrivedAt` is the uptime at which it was queued. + private var blFrameQueue: [Int64: (buffer: CVPixelBuffer, pts: CMTime, ticket: UInt64, seq: UInt64, side: FrameSideData, deadline: Double, arrivedAt: TimeInterval)] = [:] private var elFrameQueue: [Int64: CVPixelBuffer] = [:] /// Maximum layer skew in frames (dolby-vision-fel.md §5.5): a waiting base-layer /// frame is bypassed under its own arrival ticket once this many newer /// base-layer frames have arrived. Starts at 3; the 1 Hz summary's maximum skew - /// counter (§5.11) is what measures whether it can tighten. + /// counter (§5.11) is what measures whether it can tighten. This is the rule only + /// when the host gave no render clock; with one the frame's presentation deadline + /// decides (`collectDueBypasses`). static let maxLayerSkewFrames = 3 + /// What the host's render clock reads: its position in source seconds, whether it is advancing, and + /// whether it is held only because a start (after a seek or at the beginning) is waiting for the + /// renderer to fill. A clock held for any other reason is a pause. + struct RenderClockSample: Sendable { + var seconds: Double + var running: Bool + var prerolling: Bool = false + /// Playback rate, for how soon a deadline can come due; 1 when the host does not say. + var rate: Double = 1 + } + + /// The render clock, when the host has one. Set once by the host before the first packet; with none + /// (offline probes, tests) a waiting base frame is released by the count of newer arrivals alone. + /// Guarded by `lock`. + private var renderClock: (@Sendable () -> RenderClockSample?)? + /// Seconds per frame of the stream, for the deadline margin. 24 fps until the host says otherwise, + /// the same fallback the renderer's look-ahead gate uses. Guarded by `lock`. + private var deadlineFrameInterval: Double = 1.0 / 24 + + /// Installs the render clock and the stream's frame interval. Called by the host at load. + func setRenderClock(_ clock: (@Sendable () -> RenderClockSample?)?, frameRate: Double?) { + lock.lock() + renderClock = clock + if let frameRate, frameRate.isFinite, frameRate > 0 { deadlineFrameInterval = 1 / frameRate } + lock.unlock() + } + + /// Uptime source for the wait on a held clock; tests replace it. + var uptimeProvider: @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } + + /// How many frame intervals before its presentation time a waiting base frame is given up on and + /// sent out as its base layer. It is the composer's pipeline depth (`DoviFelComposer.maxFramesInFlight`): + /// a frame released this far ahead of its time has room for every composition queued ahead of it, + /// so the release costs no lateness. + static let deadlineMarginFrames = DoviFelComposer.maxFramesInFlight + /// The margin never drops below this many seconds, so a high frame rate (where three intervals are + /// 50 ms at 60 fps) does not shrink the time a late enhancement layer has to arrive. + static let deadlineMarginFloor: Double = 0.1 + /// The margin in seconds: `deadlineMarginFrames` frame intervals, or `deadlineMarginFloor`. + /// The same figure is the longest a base frame waits for a late enhancement layer while the clock + /// is held for a preroll, where there is no presentation time to count down to. + /// The caller holds `lock`. + private var deadlineMargin: Double { + max(Double(Self.deadlineMarginFrames) * deadlineFrameInterval, Self.deadlineMarginFloor) + } + /// A base frame is given at least this long, from its arrival, for its enhancement layer to follow + /// before the deadline rule may release it: one frame interval, but not under `enhancementGraceFloor`. + /// The two layers decode on separate sessions, so the partner of a frame that arrives already inside + /// its margin is normally a moment behind, not lost. Never more than `enhancementGraceCap`, so a low + /// frame rate does not hold a frame for long. A frame already past its presentation time is given + /// none (`graceFor`). The caller holds `lock`. + private var enhancementGrace: Double { min(max(deadlineFrameInterval, Self.enhancementGraceFloor), Self.enhancementGraceCap) } + static let enhancementGraceFloor: Double = 0.015 + static let enhancementGraceCap: Double = 0.05 + /// The grace a waiting frame is given with a running clock at `clockSeconds`: none once its + /// presentation time (`deadline`, the earliest of its own and those behind it) has been reached, since + /// a partner arriving after that is late whatever is done. + private func graceFor(deadline: Double, clockSeconds: Double) -> Double { + deadline <= clockSeconds ? 0 : enhancementGrace + } + /// How long a frame waits when the clock is held and no start is pending (a pause, or before the + /// clock is armed): nothing is late there, so this only bounds a layer that is lost for good. + static let heldClockWallBound: Double = 2 + /// Count backstop alongside the deadline rule. `OrderedDelivery` skips a ticket once more than + /// `maxFramesInFlight + maxLayerSkew + 2` frames are pending behind it (8 now), where the backstop + /// fires at `maxFramesInFlight + maxLayerSkewFrames + 1` newer arrivals (7): a margin of two, so + /// the frames queued behind a waiting one cannot reach the point where the stall guard skips its + /// ticket. Bounds the queue as well. + static let maxDeadlineHoldFrames = DoviFelComposer.maxFramesInFlight + maxLayerSkewFrames + 1 + /// A waiting base frame is bypassed once K newer ones have arrived, so an enhancement picture /// restamped K or more frames back would find its partner gone: the alignment declines to place those. + /// This is the count rule's K (`maxLayerSkewFrames`), still the figure with no render clock. With one + /// a frame waits up to `maxDeadlineHoldFrames` arrivals, longer than K, so the alignment's bound is + /// conservative there: it declines placements the deadline rule could have absorbed. static func makeAlignment() -> DualLayerPictureAlignment { var a = DualLayerPictureAlignment() a.maxBackwardRestamp = maxLayerSkewFrames - 1 @@ -358,12 +464,22 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { /// arrival (dolby-vision-fel.md §5.5). Not "older than the newest": output is in decode /// order, so older-PTS base frames settle after newer ones. Guarded by `lock`. private var settledBlKeys: [Int64] = [] + /// Keys of access units that carried no enhancement-layer NAL, noted in `decode(packet:)` before the + /// base layer is fed, so the base frame bypasses on arrival instead of waiting for a partner that + /// cannot come. Bounded like `settledBlKeys`. Guarded by `lock`. + private var elAbsentKeys: [Int64] = [] + /// Next sweep: `deadlineSweepScheduled` while one is armed, `sweepGeneration` to retire an older timer + /// when an earlier one replaces it, `sweepFireUptime` for when the armed one fires. Guarded by `lock`. + private var deadlineSweepScheduled = false + private var sweepGeneration: UInt64 = 0 + private var sweepFireUptime: TimeInterval = .infinity /// How many settled base-layer keys are remembered (dolby-vision-fel.md §5.5). static let settledKeyMemory = 64 /// Last parameters that came from a real RPU, and how many frames in a row have borrowed them. Both /// move in decode order, in `recordSideData`. private var lastGoodParams: DoviMetalParams? private var lastGoodDMNAL: [UInt8]? + private var lastGoodBaseLayerNAL: [UInt8]? private var borrowedParamsRun = 0 /// A frame whose RPU is missing or failed its CRC may borrow the previous frame's parameters /// for up to this many frames in a row. Reshaping and NLQ parameters are constant across a scene, @@ -381,17 +497,41 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { /// Guarded by `composeResultLock`. private var totalComposedFrames: Int = 0 private var summaryComposeFailures: Int = 0 + /// Frames dropped because an in-place composition failed after the GPU had started writing into the + /// base-layer buffer. Guarded by `composeResultLock`. + private var summaryInPlaceDropped: Int = 0 /// Compositions started, and compositions whose frame (composed or fallback) has been handed to /// `orderedDelivery`. Equal when nothing is on the GPU or in a completion handler. Guarded by /// `composeResultLock`; read by `drainForEndOfStream` and `quiesceForTesting`. private var composeRequestsStarted = 0 private var composeRequestsHandedOff = 0 - private var summaryBypassNoEL: Int = 0 + /// Base frames sent out without a partner, by the rule that released them. Guarded by `lock`. + /// The access unit carried no enhancement layer; its presentation deadline less the margin came + /// while the clock ran; the wait bound passed while the clock was held; the count of newer + /// arrivals was reached (the stall-guard backstop, or the count rule when there is no clock); + /// the enhancement session was missing; the stream ended with the frame still waiting. + private var summaryBypassNoELInAU: Int = 0 + private var summaryBypassDeadline: Int = 0 + private var summaryBypassHeldWall: Int = 0 + private var summaryBypassBackstop: Int = 0 + private var summaryBypassNoELDecoder: Int = 0 + private var summaryBypassEndOfStream: Int = 0 private var summaryBypassNoRPU: Int = 0 private var summaryDropStaleEL: Int = 0 private var summaryPrunedEL: Int = 0 private var summaryMaxObservedSkew: Int = 0 private var lastSummaryTimestamp: TimeInterval = 0 + /// Session totals behind the info-level counter line (`totalsLine`). Unlike the per-second + /// `summary*` counters they survive `flush()`, so a seek does not restart them; `close()` resets + /// them. Guarded by `lock`. + private var totalComposed: Int = 0 + private var totalBypassDeadline: Int = 0 + private var totalBypassHeldWall: Int = 0 + private var totalBypassBackstop: Int = 0 + /// When the totals line last went out, and what it said (the sum of the totals), so the + /// next one is rate limited and only goes out when a count moved. Guarded by `lock`. + private var lastTotalsLogTimestamp: TimeInterval? + private var lastLoggedTotalsSum: Int = 0 /// Held for the whole of `flush()` and `close()`, so two of them (the EOF flush on the demux /// thread against a seek flush on the main actor, or either against `close()`) run one after @@ -573,7 +713,7 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { if ptsKey != Int64.min { // In decode order, here, so every frame carries concrete parameters by the time it pairs: // compose time runs in completion order, which is not stream order. - recordSideData(key: ptsKey, params: layers.rpuParams, dmNAL: layers.convertedRpu, + recordSideData(key: ptsKey, params: layers.rpuParams, dmNAL: layers.convertedRpu, baseLayerNAL: layers.baseLayerRpu, usesPrevious: layers.usesPreviousMapping, disableResidual: layers.disableResidual) } @@ -588,6 +728,20 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { let wasReordered = pictureAlignment.reorderedUnits > 0 let elPTS = pictureAlignment.enhancementPTS(data, Int(packet.pointee.size), framing: .lengthPrefixed(size: 4), pts: ptsRaw) let nowReordered = pictureAlignment.reorderedUnits > 0 + // An access unit with no enhancement-layer NAL has no enhancement picture to wait for, but + // only where the layers' pictures sit in the same units: once any is restamped, a unit's + // enhancement picture belongs to another frame's time, so a unit without one says nothing + // about its own base frame. Judged after this unit's restamp, so it counts too. + // Known gap: a reordered stream is recognised only once its first restamp has happened, so + // the units before it (the start of each open, and again after a seek, since `flush()` + // resets the alignment) can still note an absence for a base frame whose enhancement picture + // is in another unit; such a frame is released early and shows as its base layer. The window + // is bounded to that start-up stretch, and the notes are cleared the moment reordering is seen. + if nowReordered { + elAbsentKeys.removeAll() + } else if ptsKey != Int64.min, layers.elPacket == nil { + noteEnhancementAbsent(key: ptsKey) + } lock.unlock() layers.elPacket?.pointee.pts = elPTS if nowReordered && !wasReordered { @@ -839,8 +993,13 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { /// frame instead of living in a cache `OrderedDelivery.emit` would /// otherwise have to reach back into without the decoder's lock /// (dolby-vision-fel.md §5.7). - private func resolveDisplayMetadata(key: Int64) -> [UInt8]? { - sideDataRing.removeDisplayMetadata(key: key) + /// + /// Both forms come back, because whether the frame is composed is not decided yet: `composed` has no + /// mapping, `baseLayer` keeps it. A frame whose mapping-preserved form could not be made falls back to + /// the composed one, as it carried before. + private func resolveDisplayMetadata(key: Int64) -> (composed: [UInt8]?, baseLayer: [UInt8]?) { + let forms = sideDataRing.removeDisplayMetadataForms(key: key) + return (forms.composed, forms.baseLayer ?? forms.composed) } // MARK: - Frame Pairing & Metal Composition @@ -868,7 +1027,10 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { let frameIndex: UInt32 let pts: CMTime let hdr10Data: Data? + /// The composed frame's RPU (no mapping). let dmNAL: [UInt8]? + /// The RPU with its mapping kept, for the base layer delivered in place of a composition that failed. + let baseLayerNAL: [UInt8]? /// Which arrival path produced this, for the compose-failure log. let arrived: String } @@ -879,6 +1041,7 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { let buffer: CVPixelBuffer let pts: CMTime let hdr10Data: Data? + /// The raw base layer's RPU: the mapping kept, so the display side reshapes it. let dmNAL: [UInt8]? let reason: String } @@ -908,11 +1071,14 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { guard let composer else { orderedDelivery.fulfil( ticket: req.ticket, epoch: req.epoch, - DeliveredFrame(buffer: SendableBuffer(value: req.base), pts: req.pts, hdr10Data: req.hdr10Data, dmNAL: req.dmNAL) + DeliveredFrame(buffer: SendableBuffer(value: req.base), pts: req.pts, hdr10Data: req.hdr10Data, dmNAL: req.baseLayerNAL) ) return } - if let concrete = composer as? DoviFelComposer { + // A measurement run (`AETHER_FEL_MEASURE`) composes the base layer a second and third time from + // another queue, so the base buffer has to stay intact: those runs use the output pool. + let inPlace = FelResidualMeasure.stride == 0 && composer.canComposeInPlace(baseBuffer: req.base, params: req.params) + if !inPlace, let concrete = composer as? DoviFelComposer { FelResidualMeasure.sampleIfDue(composer: concrete, base: req.base, enhancement: req.enhancement, params: req.params, frameIndex: req.frameIndex, pts: req.pts) } @@ -927,8 +1093,38 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { let pts = req.pts let hdr10Data = req.hdr10Data let dmNAL = req.dmNAL + let baseLayerNAL = req.baseLayerNAL let arrived = req.arrived let delivery = orderedDelivery + if inPlace { + // The decoded base-layer buffer is the compose target. Nothing else reads it from here until + // the completion: `req` is the only reference the pairing queues gave up, the renderer gets it + // only through `delivery`, and a failure after the GPU started writing drops the frame (the + // buffer holds half-composed pixels, so the base-layer fallback below would show them). + composer.composeInPlaceAsyncWithAcquiredSlot( + baseBuffer: req.base, + enhancementBuffer: req.enhancement, + params: req.params, + frameIndex: req.frameIndex + ) { [weak self] result in + self?.recordComposeResult(success: result == .composed) + switch result { + case .composed, .baseIntact: + if result == .baseIntact { + EngineLog.emit("[DualLayerVideoDecoder] compose failed (\(arrived)): pts=\(String(format: "%.3f", pts.seconds))", category: .swPlayback, level: .verbose) + } + // `.baseIntact` is the raw base layer, so it carries the RPU that still holds the mapping. + delivery.fulfil(ticket: ticket, epoch: epoch, DeliveredFrame(buffer: fallback, pts: pts, hdr10Data: hdr10Data, + dmNAL: result == .composed ? dmNAL : baseLayerNAL)) + case .baseCorrupted: + EngineLog.emit("[DualLayerVideoDecoder] compose failed after writing began (\(arrived)): pts=\(String(format: "%.3f", pts.seconds)); frame dropped", category: .swPlayback) + self?.recordInPlaceDrop() + delivery.skip(ticket: ticket, epoch: epoch) + } + self?.composeResultLock.lock(); self?.composeRequestsHandedOff += 1; self?.composeResultLock.unlock() + } + return + } composer.composeAsyncWithAcquiredSlot( baseBuffer: req.base, enhancementBuffer: req.enhancement, @@ -939,12 +1135,18 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { if composed == nil { EngineLog.emit("[DualLayerVideoDecoder] compose failed (\(arrived)): pts=\(String(format: "%.3f", pts.seconds))", category: .swPlayback, level: .verbose) } + // The fallback is the raw base layer, so it carries the RPU that still holds the mapping. let buffer = SendableBuffer(value: composed ?? fallback.value) - delivery.fulfil(ticket: ticket, epoch: epoch, DeliveredFrame(buffer: buffer, pts: pts, hdr10Data: hdr10Data, dmNAL: dmNAL)) + delivery.fulfil(ticket: ticket, epoch: epoch, DeliveredFrame(buffer: buffer, pts: pts, hdr10Data: hdr10Data, + dmNAL: composed != nil ? dmNAL : baseLayerNAL)) self?.composeResultLock.lock(); self?.composeRequestsHandedOff += 1; self?.composeResultLock.unlock() } } + private func recordInPlaceDrop() { + composeResultLock.lock(); summaryInPlaceDropped += 1; composeResultLock.unlock() + } + private func recordComposeResult(success: Bool) { composeResultLock.lock() if success { summaryComposedFrames += 1; totalComposedFrames += 1 } else { summaryComposeFailures += 1 } @@ -963,11 +1165,19 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { // waits for a late enhancement layer keeps its place in decode order. // The arrival may also push older waiting frames past K newer frames, // which go out here under their own tickets (`due`). + // The clock is read before `lock` is taken, so the host's clock lock never nests inside it. The + // epoch goes with it: a sample taken before a flush says nothing about the frames after it. + let (clockProvider, sampleEpoch): ((@Sendable () -> RenderClockSample?)?, UInt64) = { + lock.lock(); defer { lock.unlock() } + return (renderClock, _feedEpoch) + }() + let sampled = clockProvider?() let outcome: PairingOutcome let due: [BypassRequest] (outcome, due) = { lock.lock() defer { lock.unlock() } + let clock = _feedEpoch == sampleEpoch ? sampled : nil // Defense in depth: VT callbacks still in flight when // `close()` runs must not pair, ticket or queue into cleared state. @@ -979,27 +1189,40 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { // Not a FEL stream or secondary decoder not engaged (e.g. MEL or P8.1 fallback): emit Base Layer directly EngineLog.emit("[DualLayerVideoDecoder] bypass, single layer: isFEL=\(isFEL), isMEL=\(isMEL), enhDecoder=\(enhancementDecoder != nil)", category: .swPlayback, level: .verbose) if isFEL && !isMEL && enhancementDecoder == nil { - summaryBypassNoEL += 1 + summaryBypassNoELDecoder += 1 } let singleLayerKey = Self.canonicalKey(pts: pts) - let dmNAL = resolveDisplayMetadata(key: singleLayerKey) - return (.bypass(BypassRequest(ticket: nextDeliveryTicket(), epoch: _feedEpoch, buffer: baseBuffer, pts: pts, hdr10Data: hdr10Data, dmNAL: dmNAL, reason: "single-layer")), []) + let forms = resolveDisplayMetadata(key: singleLayerKey) + return (.bypass(BypassRequest(ticket: nextDeliveryTicket(), epoch: _feedEpoch, buffer: baseBuffer, pts: pts, hdr10Data: hdr10Data, dmNAL: forms.baseLayer, reason: "single-layer")), []) } let key = Self.canonicalKey(pts: pts) let ticket = nextDeliveryTicket() let epoch = _feedEpoch - let dmNAL = resolveDisplayMetadata(key: key) + let forms = resolveDisplayMetadata(key: key) + let dmNAL = forms.composed + let baseLayerNAL = forms.baseLayer blArrivalSeq &+= 1 let seq = blArrivalSeq + // This frame took a later ticket than everything waiting, and cannot be shown before them. + // That can move a waiting frame's due time earlier than the sweep armed for it, so every + // exit below re-arms the sweep (`collectDueBypassesAndRearm`), not only the queued one. + if pts.isNumeric { + for waitingKey in Array(blFrameQueue.keys) { + if let waiting = blFrameQueue[waitingKey] { blFrameQueue[waitingKey]?.deadline = min(waiting.deadline, pts.seconds) } + } + } var primary: PairingOutcome // A frame without a timestamp has nothing to pair on: it goes out as its base layer now, under // the ticket it already holds, rather than waiting for a partner that cannot match it. guard key != Int64.min else { EngineLog.emit("[DualLayerVideoDecoder] bypass, no timestamp: base-layer frame has nothing to pair on", category: .swPlayback, level: .verbose) - return (.bypass(BypassRequest(ticket: ticket, epoch: epoch, buffer: baseBuffer, pts: pts, hdr10Data: hdr10Data, dmNAL: dmNAL, reason: "no-pts")), collectDueBypasses()) + return (.bypass(BypassRequest(ticket: ticket, epoch: epoch, buffer: baseBuffer, pts: pts, hdr10Data: hdr10Data, dmNAL: baseLayerNAL, reason: "no-pts")), collectDueBypassesAndRearm(clock: clock)) } + // Whether the access unit at this time carried no enhancement layer. Consumed either way, but + // an enhancement frame already queued for this time wins over it: that frame is a partner. + let absentInAU = takeEnhancementAbsent(key: key) let mKey: Int64? if elFrameQueue[key] != nil { mKey = key @@ -1015,12 +1238,19 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { EngineLog.emit("[DualLayerVideoDecoder] bypass, missing RPU: key=\(key)", category: .swPlayback, level: .verbose) summaryBypassNoRPU += 1 noteSettledBaseLayer(key: key) - primary = .bypass(BypassRequest(ticket: ticket, epoch: epoch, buffer: baseBuffer, pts: pts, hdr10Data: hdr10Data, dmNAL: dmNAL, reason: "missing-rpu")) - return (primary, collectDueBypasses()) + primary = .bypass(BypassRequest(ticket: ticket, epoch: epoch, buffer: baseBuffer, pts: pts, hdr10Data: hdr10Data, dmNAL: baseLayerNAL, reason: "missing-rpu")) + return (primary, collectDueBypassesAndRearm(clock: clock)) } frameIndex &+= 1 noteSettledBaseLayer(key: key) - primary = .compose(ComposeRequest(ticket: ticket, epoch: epoch, base: baseBuffer, enhancement: elBuffer, params: params, frameIndex: frameIndex, pts: pts, hdr10Data: hdr10Data, dmNAL: dmNAL, arrived: "base layer arrived last")) + primary = .compose(ComposeRequest(ticket: ticket, epoch: epoch, base: baseBuffer, enhancement: elBuffer, params: params, frameIndex: frameIndex, pts: pts, hdr10Data: hdr10Data, dmNAL: dmNAL, baseLayerNAL: baseLayerNAL, arrived: "base layer arrived last")) + } else if absentInAU { + // Nothing queued and the access unit carried no enhancement layer: nothing is coming, + // so it goes out now. + EngineLog.emit("[DualLayerVideoDecoder] bypass, no enhancement layer in the access unit: key=\(key)", category: .swPlayback, level: .verbose) + summaryBypassNoELInAU += 1 + noteSettledBaseLayer(key: key) + return (.bypass(BypassRequest(ticket: ticket, epoch: epoch, buffer: baseBuffer, pts: pts, hdr10Data: hdr10Data, dmNAL: baseLayerNAL, reason: "no-el-in-au")), collectDueBypassesAndRearm(clock: clock)) } else { // EL frame has not arrived yet: queue Base Layer frame under its ticket EngineLog.emit("[DualLayerVideoDecoder] queued base layer: pts=\(String(format: "%.3f", pts.seconds)) key=\(key), elCount=\(elFrameQueue.count)", category: .swPlayback, level: .verbose) @@ -1029,20 +1259,20 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { // Same canonical key twice: the old entry's ticket would stall // delivery forever if dropped, so bypass it under its ticket, // carrying its stored side data. - displaced = BypassRequest(ticket: old.ticket, epoch: epoch, buffer: old.buffer, pts: old.pts, hdr10Data: old.side.hdr10Data, dmNAL: old.side.dmNAL, reason: "duplicate-key") + displaced = BypassRequest(ticket: old.ticket, epoch: epoch, buffer: old.buffer, pts: old.pts, hdr10Data: old.side.hdr10Data, dmNAL: old.side.baseLayerNAL, reason: "duplicate-key") noteSettledBaseLayer(key: key) } - blFrameQueue[key] = (buffer: baseBuffer, pts: pts, ticket: ticket, seq: seq, side: FrameSideData(hdr10Data: hdr10Data, dmNAL: dmNAL)) + blFrameQueue[key] = (buffer: baseBuffer, pts: pts, ticket: ticket, seq: seq, side: FrameSideData(hdr10Data: hdr10Data, dmNAL: dmNAL, baseLayerNAL: baseLayerNAL), deadline: pts.isNumeric ? pts.seconds : .infinity, arrivedAt: renderClock != nil ? uptimeProvider() : 0) let currentSkew = max(blFrameQueue.count, elFrameQueue.count) if currentSkew > summaryMaxObservedSkew { summaryMaxObservedSkew = currentSkew } primary = .queued - var due = collectDueBypasses() + var due = collectDueBypassesAndRearm(clock: clock) if let displaced { due.insert(displaced, at: 0) } return (primary, due) } - return (primary, collectDueBypasses()) + return (primary, collectDueBypassesAndRearm(clock: clock)) }() // Older tickets first; `orderedDelivery` reorders by ticket anyway. @@ -1099,11 +1329,11 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { EngineLog.emit("[DualLayerVideoDecoder] bypass, missing RPU: key=\(mKey)", category: .swPlayback, level: .verbose) summaryBypassNoRPU += 1 noteSettledBaseLayer(key: mKey) - return .bypass(BypassRequest(ticket: waiting.ticket, epoch: epoch, buffer: waiting.buffer, pts: waiting.pts, hdr10Data: waiting.side.hdr10Data, dmNAL: waiting.side.dmNAL, reason: "missing-rpu")) + return .bypass(BypassRequest(ticket: waiting.ticket, epoch: epoch, buffer: waiting.buffer, pts: waiting.pts, hdr10Data: waiting.side.hdr10Data, dmNAL: waiting.side.baseLayerNAL, reason: "missing-rpu")) } frameIndex &+= 1 noteSettledBaseLayer(key: mKey) - return .compose(ComposeRequest(ticket: waiting.ticket, epoch: epoch, base: waiting.buffer, enhancement: elBuffer, params: params, frameIndex: frameIndex, pts: waiting.pts, hdr10Data: waiting.side.hdr10Data, dmNAL: dmNAL, arrived: "enhancement layer arrived last")) + return .compose(ComposeRequest(ticket: waiting.ticket, epoch: epoch, base: waiting.buffer, enhancement: elBuffer, params: params, frameIndex: frameIndex, pts: waiting.pts, hdr10Data: waiting.side.hdr10Data, dmNAL: dmNAL, baseLayerNAL: waiting.side.baseLayerNAL, arrived: "enhancement layer arrived last")) } else { // Base Layer frame has not arrived yet: queue EL frame under its own larger cap EngineLog.emit("[DualLayerVideoDecoder] queued enhancement layer: pts=\(String(format: "%.3f", pts.seconds)) key=\(key), blCount=\(blFrameQueue.count)", category: .swPlayback, level: .verbose) @@ -1145,12 +1375,14 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { /// - A missing or corrupt RPU may borrow the previous mapping for up to `maxBorrowedParamsRun` frames in /// a row; reshaping and NLQ parameters are constant across a scene. Past that the frame carries none /// and goes out as its base layer. - private func recordSideData(key: Int64, params: DoviMetalParams?, dmNAL: [UInt8]?, usesPrevious: Bool, disableResidual: Bool = false) { + private func recordSideData(key: Int64, params: DoviMetalParams?, dmNAL: [UInt8]?, baseLayerNAL: [UInt8]? = nil, usesPrevious: Bool, disableResidual: Bool = false) { var resolvedParams = params var resolvedNAL = dmNAL + var resolvedBaseLayerNAL = baseLayerNAL if let params { lastGoodParams = params lastGoodDMNAL = dmNAL + lastGoodBaseLayerNAL = baseLayerNAL borrowedParamsRun = 0 } else if let last = lastGoodParams { if usesPrevious { @@ -1161,16 +1393,18 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { if disableResidual { borrowed.flags.insert(.disableResidual) } else { borrowed.flags.remove(.disableResidual) } resolvedParams = borrowed resolvedNAL = dmNAL ?? lastGoodDMNAL + resolvedBaseLayerNAL = baseLayerNAL ?? lastGoodBaseLayerNAL } else if borrowedParamsRun < Self.maxBorrowedParamsRun { borrowedParamsRun += 1 summaryBorrowedParams += 1 resolvedParams = last resolvedNAL = dmNAL ?? lastGoodDMNAL + resolvedBaseLayerNAL = baseLayerNAL ?? lastGoodBaseLayerNAL EngineLog.emit("[DualLayerVideoDecoder] RPU missing for key=\(key); reusing the previous frame's parameters (\(borrowedParamsRun)/\(Self.maxBorrowedParamsRun))", category: .swPlayback, level: .verbose) } } guard resolvedParams != nil || resolvedNAL != nil else { return } - sideDataRing.insert(key: key, params: resolvedParams, dmNAL: resolvedNAL) + sideDataRing.insert(key: key, params: resolvedParams, dmNAL: resolvedNAL, baseLayerNAL: resolvedBaseLayerNAL) } /// The parameters `recordSideData` resolved for `key`, nil when the frame has none. The caller holds `lock`. @@ -1193,31 +1427,176 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { } } - /// Bypasses every queued base-layer frame that has seen K newer base-layer - /// arrivals, oldest first, each under the ticket its arrival took. - /// The caller holds `lock`. - private func collectDueBypasses() -> [BypassRequest] { + /// Records that the access unit at `key` carried no enhancement layer. The caller holds `lock`. + func noteEnhancementAbsent(key: Int64) { + elAbsentKeys.append(key) + if elAbsentKeys.count > Self.settledKeyMemory { elAbsentKeys.removeFirst(elAbsentKeys.count - Self.settledKeyMemory) } + } + + /// Removes and reports whether `key` was noted as having no enhancement layer. The caller holds `lock`. + private func takeEnhancementAbsent(key: Int64) -> Bool { + guard let i = elAbsentKeys.firstIndex(where: { FrameSideDataRing.isWithinJitter($0, key) }) else { return false } + elAbsentKeys.remove(at: i) + return true + } + + /// The rule that released a waiting base frame without its partner. + private enum ReleaseCause { + case deadline, heldWall, backstop + + var reason: String { + switch self { + case .deadline: return "no-el-deadline" + case .heldWall: return "no-el-held-wall" + case .backstop: return "no-el-backstop" + } + } + } + + /// Releases every queued base-layer frame that can no longer wait for its enhancement layer, oldest + /// first, each under the ticket its arrival took. The caller holds `lock`. + /// + /// With a running render clock a frame is due once its presentation time less the margin + /// (`deadlineMargin`) has been reached, and not before `enhancementGrace` has passed since it + /// arrived: a frame that arrives already inside its margin gets that long for a partner that is only + /// a moment behind. A frame already at or past its presentation time gets no grace. Its presentation + /// time is the earliest of its own and every later-ticket frame's, since delivery is ordered by + /// ticket and the frame behind it cannot show first. With the clock held + /// there is no presentation time to count down to: for a start that waits on the renderer (a preroll + /// after a seek) it waits the margin of wall time at most; for a pause, or before the clock is armed, + /// nothing is late and it waits `heldClockWallBound`. With no clock at all it is released by the count + /// of newer arrivals (`maxLayerSkewFrames`). A count of `maxDeadlineHoldFrames` releases it in every + /// mode with a clock. + private func collectDueBypasses(clock: RenderClockSample?) -> [BypassRequest] { + let hasClock = renderClock != nil + let margin = deadlineMargin + let now = hasClock ? uptimeProvider() : 0 var due: [BypassRequest] = [] for k in blFrameQueue.keys.sorted() { - guard let entry = blFrameQueue[k], - blArrivalSeq &- entry.seq >= UInt64(Self.maxLayerSkewFrames) else { continue } + guard let entry = blFrameQueue[k] else { continue } + let newer = blArrivalSeq &- entry.seq + let waited = now - entry.arrivedAt + let cause: ReleaseCause? + if !hasClock { + cause = newer >= UInt64(Self.maxLayerSkewFrames) ? .backstop : nil + } else if newer >= UInt64(Self.maxDeadlineHoldFrames) { + cause = .backstop + } else if let clock, clock.running, clock.seconds.isFinite { + cause = waited >= graceFor(deadline: entry.deadline, clockSeconds: clock.seconds) && entry.deadline - margin <= clock.seconds ? .deadline : nil + } else { + let bound = clock?.prerolling == true ? margin : Self.heldClockWallBound + cause = waited >= bound ? .heldWall : nil + } + guard let cause else { continue } blFrameQueue.removeValue(forKey: k) - summaryBypassNoEL += 1 - EngineLog.emit("[DualLayerVideoDecoder] bypass, no enhancement layer arrived: pts=\(String(format: "%.3f", entry.pts.seconds)) key=\(k)", category: .swPlayback, level: .verbose) + switch cause { + case .deadline: summaryBypassDeadline += 1 + case .heldWall: summaryBypassHeldWall += 1 + case .backstop: summaryBypassBackstop += 1 + } + EngineLog.emit("[DualLayerVideoDecoder] bypass, no enhancement layer arrived (\(cause.reason)): pts=\(String(format: "%.3f", entry.pts.seconds)) key=\(k)", category: .swPlayback, level: .verbose) noteSettledBaseLayer(key: k) - due.append(BypassRequest(ticket: entry.ticket, epoch: _feedEpoch, buffer: entry.buffer, pts: entry.pts, hdr10Data: entry.side.hdr10Data, dmNAL: entry.side.dmNAL, reason: "no-el")) + due.append(BypassRequest(ticket: entry.ticket, epoch: _feedEpoch, buffer: entry.buffer, pts: entry.pts, hdr10Data: entry.side.hdr10Data, dmNAL: entry.side.baseLayerNAL, reason: cause.reason)) } - // Every arrival takes a new `seq`, so what survives the loop arrived within the last - // `maxLayerSkewFrames` base frames: the queue is bounded by the skew rule alone. - assert(blFrameQueue.count <= Self.maxLayerSkewFrames) + assert(blFrameQueue.count <= (hasClock ? Self.maxDeadlineHoldFrames : Self.maxLayerSkewFrames)) + return due + } + + /// `collectDueBypasses`, then arms the next sweep for whatever still waits (a no-op with nothing + /// waiting). An arrival can lower a waiting frame's deadline whichever way it is itself handled, so + /// the sweep armed for the old, later time has to be replaced by one for the new time. The caller + /// holds `lock`. + private func collectDueBypassesAndRearm(clock: RenderClockSample?) -> [BypassRequest] { + let due = collectDueBypasses(clock: clock) + scheduleDeadlineSweepIfNeeded(clock: clock) return due } + /// Re-checks the waiting frames against the clock between arrivals: the deadline passes with + /// time, and no arrival need come to say so. One sweep is armed at a time, for the earliest moment + /// any waiting frame can become due (`nextSweepDelay`). + private static let deadlineSweepQueue = DispatchQueue(label: "aether.dovi.deadline-sweep", qos: .userInteractive) + /// Bounds on the wait to the next sweep: never a busy loop, and never so long that a rate change or a + /// moved clock goes unnoticed. + private static let minSweepDelay: TimeInterval = 0.002 + private static let maxSweepDelay: TimeInterval = 0.25 + + /// Seconds until the first waiting frame could be released by time alone, from `now`, clamped to the + /// sweep bounds; nil when nothing waits. The caller holds `lock`. + private func nextSweepDelay(clock: RenderClockSample?, now: TimeInterval) -> TimeInterval? { + guard !blFrameQueue.isEmpty else { return nil } + let margin = deadlineMargin + var earliest = Double.infinity + for entry in blFrameQueue.values { + let due: TimeInterval + if let clock, clock.running, clock.seconds.isFinite { + // Media time to wall time at the host's rate (the engine plays at 0.5 to 2). + let untilDeadline = (entry.deadline - margin - clock.seconds) / max(clock.rate, 0.5) + // The grace ends at the earlier of its own end and the presentation time, where it drops to + // none: a frame whose time falls inside its grace is due then, not when the grace is over. + let graceEnd = entry.arrivedAt + graceFor(deadline: entry.deadline, clockSeconds: clock.seconds) - now + let untilPresentation = (entry.deadline - clock.seconds) / max(clock.rate, 0.5) + due = max(min(graceEnd, untilPresentation), untilDeadline) + } else { + due = entry.arrivedAt + (clock?.prerolling == true ? margin : Self.heldClockWallBound) - now + } + earliest = min(earliest, due) + } + return min(max(earliest, Self.minSweepDelay), Self.maxSweepDelay) + } + + /// Arms the next sweep for the earliest due time, replacing an armed one that would fire later. + /// Caller holds `lock`. + private func scheduleDeadlineSweepIfNeeded(clock: RenderClockSample?) { + guard renderClock != nil, !isClosed, !draining else { return } + let now = uptimeProvider() + guard let delay = nextSweepDelay(clock: clock, now: now) else { return } + let fire = now + delay + if deadlineSweepScheduled, sweepFireUptime <= fire + 0.001 { return } + sweepGeneration &+= 1 + let generation = sweepGeneration + deadlineSweepScheduled = true + sweepFireUptime = fire + Self.deadlineSweepQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.runDeadlineSweep(generation: generation) + } + } + + /// Internal rather than private so tests can drive it without waiting on the timer. + func runDeadlineSweep() { + runDeadlineSweep(generation: nil) + } + + /// `generation` is the timer's own; one that has been replaced since does nothing. + private func runDeadlineSweep(generation: UInt64?) { + lock.lock() + if let generation, generation != sweepGeneration { lock.unlock(); return } + // Whatever else is armed is stale from here: this sweep re-arms as needed. + sweepGeneration &+= 1 + deadlineSweepScheduled = false + let provider = renderClock + let epoch = _feedEpoch + lock.unlock() + let clock = provider?() + let due: [BypassRequest] = { + lock.lock() + defer { lock.unlock() } + // Draining or flushed meanwhile: the queue it was for is gone, and the next arrival re-arms. + guard !isClosed, !draining, _feedEpoch == epoch else { return [] } + let due = collectDueBypasses(clock: clock) + scheduleDeadlineSweepIfNeeded(clock: clock) + return due + }() + for req in due { fulfilBypass(req) } + } + #if DEBUG var summaryTimestampProviderForTesting: (() -> TimeInterval)? /// Sees every summary line with the level it went out at. `.verbose` lines never reach /// `EngineLog.handler`, so a test cannot observe them there. var summarySinkForTesting: ((String, EngineLog.Level) -> Void)? + /// Sees every info-level totals line (`totalsLine`). + var totalsSinkForTesting: ((String) -> Void)? #endif // MARK: - Observability (dolby-vision-fel.md §5.11) @@ -1248,42 +1627,84 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { composeResultLock.lock() let composed = summaryComposedFrames let composeFailures = summaryComposeFailures + let inPlaceDropped = summaryInPlaceDropped summaryComposedFrames = 0 summaryComposeFailures = 0 + summaryInPlaceDropped = 0 composeResultLock.unlock() - let noEL = summaryBypassNoEL + let noELInAU = summaryBypassNoELInAU + let deadline = summaryBypassDeadline + let heldWall = summaryBypassHeldWall + let backstop = summaryBypassBackstop + let noELDecoder = summaryBypassNoELDecoder + let endOfStream = summaryBypassEndOfStream let noRPU = summaryBypassNoRPU let staleEL = summaryDropStaleEL let prunedEL = summaryPrunedEL let borrowedParams = summaryBorrowedParams let skew = summaryMaxObservedSkew - summaryBypassNoEL = 0 + summaryBypassNoELInAU = 0 + summaryBypassDeadline = 0 + summaryBypassHeldWall = 0 + summaryBypassBackstop = 0 + summaryBypassNoELDecoder = 0 + summaryBypassEndOfStream = 0 summaryBypassNoRPU = 0 summaryDropStaleEL = 0 summaryPrunedEL = 0 summaryBorrowedParams = 0 summaryMaxObservedSkew = max(blFrameQueue.count, elFrameQueue.count) lastSummaryTimestamp = now + totalComposed += composed + totalBypassDeadline += deadline + totalBypassHeldWall += heldWall + totalBypassBackstop += backstop + let totalsSum = totalComposed + totalBypassDeadline + totalBypassHeldWall + totalBypassBackstop + var totalsLine: String? + if totalsSum != lastLoggedTotalsSum, + lastTotalsLogTimestamp.map({ now - $0 >= Self.totalsLogIntervalSeconds }) ?? true { + lastTotalsLogTimestamp = now + lastLoggedTotalsSum = totalsSum + totalsLine = Self.totalsLine(composed: totalComposed, bypassDeadline: totalBypassDeadline, + bypassHeldWall: totalBypassHeldWall, bypassBackstop: totalBypassBackstop) + } let activeComposer = composer lock.unlock() let slots = activeComposer?.takePeakSlotsInUse() ?? 0 let poolBuffers = activeComposer?.allocatedPoolBuffers ?? 0 + let inPlaceTotal = activeComposer?.inPlaceComposedFrames ?? 0 let gpuMs = activeComposer?.meanGPUTimeMs let pendingPeak = orderedDelivery.takePeakPendingCount() let gpuStr = gpuMs.map { String(format: "%.1f", $0) } ?? "n/a" - let line = "[DualLayerVideoDecoder] summary: composed=\(composed), composeFailures=\(composeFailures), bypassNoEL=\(noEL), bypassNoRPU=\(noRPU), staleELDropped=\(staleEL), prunedEL=\(prunedEL), borrowedParams=\(borrowedParams), maxSkew=\(skew), peakGpuSlots=\(slots), poolBuffers=\(poolBuffers), deliveryPendingPeak=\(pendingPeak), meanGpuMs=\(gpuStr)" + let line = "[DualLayerVideoDecoder] summary: composed=\(composed), composeFailures=\(composeFailures), bypassNoELInAU=\(noELInAU), bypassDeadline=\(deadline), bypassHeldWall=\(heldWall), bypassBackstop=\(backstop), bypassNoELDecoder=\(noELDecoder), bypassEndOfStream=\(endOfStream), bypassNoRPU=\(noRPU), staleELDropped=\(staleEL), prunedEL=\(prunedEL), borrowedParams=\(borrowedParams), maxSkew=\(skew), peakGpuSlots=\(slots), poolBuffers=\(poolBuffers), inPlaceTotal=\(inPlaceTotal), inPlaceDropped=\(inPlaceDropped), deliveryPendingPeak=\(pendingPeak), meanGpuMs=\(gpuStr)" // Once a second is a trace, not an event: it goes to OSLog's debug level only. An interval in // which a composition failed is the exception, so the failure reaches the default level and the // host handler. - let level: EngineLog.Level = composeFailures > 0 ? .info : .verbose + let level: EngineLog.Level = composeFailures > 0 || inPlaceDropped > 0 ? .info : .verbose EngineLog.emit(line, category: .swPlayback, level: level) #if DEBUG summarySinkForTesting?(line, level) #endif + if let totalsLine { + EngineLog.emit(totalsLine, category: .swPlayback, level: .info) + #if DEBUG + totalsSinkForTesting?(totalsLine) + #endif + } activeComposer?.flushTextureCache() } + /// Seconds between `[DualLayerVideoDecoder] totals` lines. + static let totalsLogIntervalSeconds: TimeInterval = 10.0 + + /// The session totals of the deadline-bypass counters, at the default log level, so a device run + /// can judge them from the host's log without a verbose trace. Rate limited and sent only when a + /// count moved since the last one (`maybeEmitSummary`). + static func totalsLine(composed: Int, bypassDeadline: Int, bypassHeldWall: Int, bypassBackstop: Int) -> String { + "[DualLayerVideoDecoder] totals: composed=\(composed), bypassDeadline=\(bypassDeadline), bypassHeldWall=\(bypassHeldWall), bypassBackstop=\(bypassBackstop)" + } + // MARK: - Flush & Teardown func flush() { @@ -1332,8 +1753,10 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { // exist, and not after the drain. lastGoodParams = nil lastGoodDMNAL = nil + lastGoodBaseLayerNAL = nil borrowedParamsRun = 0 sideDataRing.removeAll() + elAbsentKeys.removeAll() let epoch = _feedEpoch let nextTicket = deliveryNextTicket let primary = primaryDecoder @@ -1386,8 +1809,8 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { // `skipUntilPTS` pre-roll discards anyway until the seek target; and a dropped output frame // does not affect decoding, VideoToolbox's reference state is intact. What it can cost is // pairing: a frame dropped on one layer and not on the other leaves its partner waiting, and - // a waiting base frame goes out base-layer only once `maxLayerSkewFrames` newer ones have - // arrived. So the first few frames after a seek can be base-layer only (how many depends on + // a waiting base frame goes out base-layer only when its display deadline passes (or the + // wall bound or the count backstop of 7 newer tickets is hit). So the first few frames after a seek can be base-layer only (how many depends on // how many frames the two sessions output during the drain), and the base frames dropped // during the drain are never shown. primary.flushKeepingEpoch() @@ -1399,10 +1822,26 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { draining = false blFrameQueue.removeAll() elFrameQueue.removeAll() + deadlineSweepScheduled = false + sweepGeneration &+= 1 blArrivalSeq = 0 settledBlKeys.removeAll() - composeResultLock.lock(); summaryComposedFrames = 0; summaryComposeFailures = 0; composeResultLock.unlock() - summaryBypassNoEL = 0 + // The session totals survive a flush, so the counts of the interval in progress are folded + // into them before the per-second counters are zeroed. + composeResultLock.lock() + totalComposed += summaryComposedFrames + summaryComposedFrames = 0 + summaryComposeFailures = 0 + composeResultLock.unlock() + totalBypassDeadline += summaryBypassDeadline + totalBypassHeldWall += summaryBypassHeldWall + totalBypassBackstop += summaryBypassBackstop + summaryBypassNoELInAU = 0 + summaryBypassDeadline = 0 + summaryBypassHeldWall = 0 + summaryBypassBackstop = 0 + summaryBypassNoELDecoder = 0 + summaryBypassEndOfStream = 0 summaryBypassNoRPU = 0 summaryDropStaleEL = 0 summaryPrunedEL = 0 @@ -1445,9 +1884,9 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { var due: [BypassRequest] = [] for k in blFrameQueue.keys.sorted() { guard let entry = blFrameQueue.removeValue(forKey: k) else { continue } - summaryBypassNoEL += 1 + summaryBypassEndOfStream += 1 noteSettledBaseLayer(key: k) - due.append(BypassRequest(ticket: entry.ticket, epoch: _feedEpoch, buffer: entry.buffer, pts: entry.pts, hdr10Data: entry.side.hdr10Data, dmNAL: entry.side.dmNAL, reason: "end-of-stream")) + due.append(BypassRequest(ticket: entry.ticket, epoch: _feedEpoch, buffer: entry.buffer, pts: entry.pts, hdr10Data: entry.side.hdr10Data, dmNAL: entry.side.baseLayerNAL, reason: "end-of-stream")) } return due }() @@ -1529,20 +1968,35 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { pictureAlignment = Self.makeAlignment() blFrameQueue.removeAll() elFrameQueue.removeAll() + elAbsentKeys.removeAll() + deadlineSweepScheduled = false + sweepGeneration &+= 1 blArrivalSeq = 0 settledBlKeys.removeAll() lastGoodParams = nil lastGoodDMNAL = nil + lastGoodBaseLayerNAL = nil borrowedParamsRun = 0 sideDataRing.removeAll() composeResultLock.lock(); summaryComposedFrames = 0; summaryComposeFailures = 0; composeResultLock.unlock() - summaryBypassNoEL = 0 + summaryBypassNoELInAU = 0 + summaryBypassDeadline = 0 + summaryBypassHeldWall = 0 + summaryBypassBackstop = 0 + summaryBypassNoELDecoder = 0 + summaryBypassEndOfStream = 0 summaryBypassNoRPU = 0 summaryDropStaleEL = 0 summaryPrunedEL = 0 summaryBorrowedParams = 0 summaryMaxObservedSkew = 0 lastSummaryTimestamp = 0 + totalComposed = 0 + totalBypassDeadline = 0 + totalBypassHeldWall = 0 + totalBypassBackstop = 0 + lastTotalsLogTimestamp = nil + lastLoggedTotalsSum = 0 isFEL = false isMEL = false streamClassified = false @@ -1839,15 +2293,39 @@ final class DualLayerVideoDecoder: VideoDecodingPipeline, @unchecked Sendable { ) } - /// Caches what `decode(packet:)` would have cached for the packet at `pts`: the composer's - /// reshaping parameters and the Profile 8.1 RPU NAL attached as display metadata. + /// Notes that the access unit at `pts` carried no enhancement layer, whatever the stream. The rule + /// that a reordered stream makes no such note lives in `decode(packet:)`; tests of it feed access + /// units through that path instead of this one. + func noteEnhancementAbsentForTesting(pts: CMTime) { + lock.lock() + defer { lock.unlock() } + noteEnhancementAbsent(key: Self.canonicalKey(pts: pts)) + } + + /// Marks the primary session as open, so `decode(packet:)` runs past its readiness gate in tests + /// that feed access units through the real path with a stand-in primary decoder. + func markPrimaryDecoderOpenForTesting() { + lock.lock() + defer { lock.unlock() } + primaryDecoderInitialized = true + } + + /// Whether a deadline sweep is armed, and how many access units are noted as having no enhancement layer. + var deadlineSweepArmedForTesting: Bool { lock.lock(); defer { lock.unlock() }; return deadlineSweepScheduled } + /// The uptime the armed sweep fires at, nil when none is armed. + var armedSweepFireUptimeForTesting: TimeInterval? { + lock.lock(); defer { lock.unlock() } + return deadlineSweepScheduled ? sweepFireUptime : nil + } + var absentEnhancementKeyCountForTesting: Int { lock.lock(); defer { lock.unlock() }; return elAbsentKeys.count } + /// One call is one packet, in decode order: a nil `params` is a packet with a missing RPU (or, with /// `usesPrevious`, one that says to keep the previous mapping), resolved exactly as in production. - func seedRPUForTesting(pts: CMTime, params: DoviMetalParams?, displayMetadataNAL: [UInt8]?, usesPrevious: Bool = false, disableResidual: Bool = false) { + func seedRPUForTesting(pts: CMTime, params: DoviMetalParams?, displayMetadataNAL: [UInt8]?, baseLayerNAL: [UInt8]? = nil, usesPrevious: Bool = false, disableResidual: Bool = false) { lock.lock() defer { lock.unlock() } let key = Self.canonicalKey(pts: pts) - recordSideData(key: key, params: params, dmNAL: displayMetadataNAL, usesPrevious: usesPrevious, disableResidual: disableResidual) + recordSideData(key: key, params: params, dmNAL: displayMetadataNAL, baseLayerNAL: baseLayerNAL, usesPrevious: usesPrevious, disableResidual: disableResidual) } } #endif diff --git a/Sources/AetherEngine/Decoder/FrameSideDataRing.swift b/Sources/AetherEngine/Decoder/FrameSideDataRing.swift index bd6c5d3cf..80bee4ce8 100644 --- a/Sources/AetherEngine/Decoder/FrameSideDataRing.swift +++ b/Sources/AetherEngine/Decoder/FrameSideDataRing.swift @@ -4,13 +4,20 @@ import Foundation /// Fixed-capacity ring buffer for Dolby Vision per-frame side data (dolby-vision-fel.md §5.3). /// -/// Stores `{ params, dmNAL }` keyed by canonical PTS. +/// Stores `{ params, dmNAL, baseLayerNAL }` keyed by canonical PTS. /// Fixed capacity of 32 entries (reorder depth plus maximum layer skew with margin). /// Insert overwrites the oldest entry when full. -/// Lookup scans up to 32 entries for the nearest key within ±300 ticks (~3.3 ms). +/// Lookup scans up to 32 entries for the nearest key within ±300 ticks. +/// +/// Keys are in a 90 kHz time base, always: the owner builds every key with +/// `DualLayerVideoDecoder.canonicalKey`, which rescales the stream's time base (Matroska's 1/1000, +/// MPEG-TS's 1/90000, whatever the demuxer reports) to 90 kHz, and nothing else inserts or looks up. +/// So the window is ±300 / 90 000 s = ±3.3 ms; fed raw 1/1000 ticks it would be ±300 ms, several +/// frames wide. `jitterToleranceTicks` is therefore in 90 kHz ticks, not in any stream's. /// No heap reallocation on insert, no sorting, no dictionary overhead. struct FrameSideDataRing: Sendable { static let capacity = 32 + /// ±3.3 ms, in the 90 kHz time base every key is in. static let jitterToleranceTicks: Int64 = 300 /// Distance between two canonical keys, or nil when either is the missing-PTS key (`Int64.min`) or the @@ -32,11 +39,15 @@ struct FrameSideDataRing: Sendable { var key: Int64 var params: DoviMetalParams? var dmNAL: [UInt8]? + /// The same RPU with its mapping kept, for the frame if it goes out as its raw base layer + /// (`DoviRpuConverter.DualLayerPacketResult.baseLayerRpu`); `dmNAL` is the composed frame's form. + var baseLayerNAL: [UInt8]? - init(key: Int64, params: DoviMetalParams? = nil, dmNAL: [UInt8]? = nil) { + init(key: Int64, params: DoviMetalParams? = nil, dmNAL: [UInt8]? = nil, baseLayerNAL: [UInt8]? = nil) { self.key = key self.params = params self.dmNAL = dmNAL + self.baseLayerNAL = baseLayerNAL } } @@ -72,19 +83,20 @@ struct FrameSideDataRing: Sendable { /// Inserts or updates an entry for `key`. /// If an entry already exists for this exact key, updates its fields. /// Otherwise overwrites the oldest slot in the ring. - mutating func insert(key: Int64, params: DoviMetalParams? = nil, dmNAL: [UInt8]? = nil) { + mutating func insert(key: Int64, params: DoviMetalParams? = nil, dmNAL: [UInt8]? = nil, baseLayerNAL: [UInt8]? = nil) { // If an entry exists for the exact key, update in-place without consuming a new slot: for i in 0.. [UInt8]? { + removeDisplayMetadataForms(key: key).composed + } + + /// Removes and returns both RPU forms for `key` (or nearest within ±300 ticks): `composed` is + /// `dmNAL`, `baseLayer` is `baseLayerNAL`. + mutating func removeDisplayMetadataForms(key: Int64) -> (composed: [UInt8]?, baseLayer: [UInt8]?) { var bestIndex: Int? var minDiff = UInt64.max for i in 0..= nextToEmit else { + os_signpost(.end, log: Self.signpostLog, name: "Delivery", signpostID: signpostID, "dropped") + return + } + pending[ticket] = PendingEntry(frame: nil, signpostID: signpostID) + updatePendingCounts() + drainReady() + } + } + /// Bumps the epoch and fast-forwards the head of the line to `nextTicket`, /// dropping anything still pending behind it. Enqueues the work /// (`queue.async`) rather than waiting for it, so this is safe to call @@ -246,7 +266,7 @@ final class OrderedDelivery: @unchecked Sendable { if let entry = pending.removeValue(forKey: nextToEmit) { nextToEmit &+= 1 stallLogPending = false - emit(entry.frame) + if let frame = entry.frame { emit(frame) } os_signpost(.end, log: Self.signpostLog, name: "Delivery", signpostID: entry.signpostID) continue } diff --git a/Sources/AetherEngine/Native/SoftwarePlaybackHost+AudioProducer.swift b/Sources/AetherEngine/Native/SoftwarePlaybackHost+AudioProducer.swift index 48ba58881..5b94daf70 100644 --- a/Sources/AetherEngine/Native/SoftwarePlaybackHost+AudioProducer.swift +++ b/Sources/AetherEngine/Native/SoftwarePlaybackHost+AudioProducer.swift @@ -22,7 +22,14 @@ extension SoftwarePlaybackHost { try decoder.open(stream: stream) return (decoder, .pcm) } - guard objectAudioRendering != .off, !isLive else { return try pcm() } + guard objectAudioRendering != .off, !isLive else { + if let note = Self.eac3PCMRouteNote( + codecID: par.pointee.codec_id, rendering: objectAudioRendering, isLive: isLive, + dualLayerRoute: decodesDualLayer) { + EngineLog.emit(note, category: .swPlayback) + } + return try pcm() + } // The look at the stream and the DTS:X verdict run off the main actor, and a stop() can land // while they do: it closes the demuxer, which frees the streams `stream` and `par` point into. @@ -94,6 +101,19 @@ extension SoftwarePlaybackHost { } } + /// The route line for an E-AC-3 track on the Profile 7 FEL dual-layer route when nothing asks for the + /// objects: the track is decoded to PCM, so a JOC (Atmos) track plays as its channel bed. nil for any + /// other codec or route, and when the object path ran (it logs its own verdict). `makeAudioProducer` + /// runs once per session, which makes this one line per session. + nonisolated static func eac3PCMRouteNote( + codecID: AVCodecID, rendering: ObjectAudioRendering, isLive: Bool, dualLayerRoute: Bool + ) -> String? { + guard dualLayerRoute, codecID == AV_CODEC_ID_EAC3, rendering == .off || isLive else { return nil } + let why = isLive ? "live source" : "objectAudioRendering off" + return "[SWHost] audio: E-AC-3 on the dual-layer route is decoded to PCM (\(why)); " + + "a JOC (Atmos) track plays as its channel bed, without the objects" + } + /// What the APAC route does with `makeAPACProducer`'s answer. A stop() that landed during the /// verdict await makes the answer nil on purpose, and the PCM decode the caller falls back to on /// a nil would open a stream the closing demuxer has freed: so a stop is checked before either, diff --git a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift index 39a91abde..f359f1184 100644 --- a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift +++ b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift @@ -1480,6 +1480,20 @@ final class SoftwarePlaybackHost { // (`AudioOutput.videoPrerollSatisfied`, #774, #800). A live stream off the FEL path starts at // once, and so does a session playing audio only in the background, which feeds the renderer // nothing. + #if DualLayerDolbyVision + // The dual-layer decoder releases a base frame that lost its enhancement layer by presentation + // deadline, which needs this clock. + if let dual = videoDecoder as? DualLayerVideoDecoder { + // The fields are read one after another, not as one atomic snapshot, so a clock that moves + // between them (a start, a rate change) can pair a time with a stale flag. The decoder + // samples again at its next arrival or sweep, within one sweep delay, which corrects it. + dual.setRenderClock({ [weak audioOutput] in + guard let audioOutput else { return nil } + return .init(seconds: audioOutput.currentTimeSeconds, running: audioOutput.rate > 0, + prerolling: audioOutput.prerollPending, rate: Double(audioOutput.rate)) + }, frameRate: AetherEngine.detectFrameRate(stream: vStream)) + } + #endif let prerollRenderer = renderer let prerollLive = isLive self.audioOutput?.videoPrerollSatisfied = { [weak self] clock in @@ -1962,6 +1976,13 @@ final class SoftwarePlaybackHost { inFlightSeekResumeIntent = wasPlaying isPlaying = false + #if DualLayerDolbyVision + // The dual-layer decoder's deadline rule reads this clock, and `seekClock` below moves it after + // this flush. That is safe only because the demux loop resumes after `noteSeekSettled`, past the + // `seekClock` call: until then no post-seek frame reaches the decoder to be judged against the + // pre-seek clock. Keep that order if either step moves. (The clock sample is not atomic either, + // see where `setRenderClock` is called; a skewed sample corrects itself within one sweep.) + #endif videoDecoder.flush() audioDecoder?.flush() // Hold the last frame through the seek (don't blank the display) so the viewer sees the previous @@ -4286,6 +4307,7 @@ final class SoftwarePlaybackHost { + (d.sourceExhausted ? "eof=y " : "") + "enq=+\(dEnq) disp=+\(dHanded) " + "lost=\(cadence.lostBeforeLayer)(\(dLost >= 0 ? "+" : "")\(dLost)) " + + "skip=\(cadence.arrivalSkipped) " + "dpts=\(spacing) layerDrop=\(dropped)(\(dDrop >= 0 ? "+" : "")\(dDrop)) " + "delay=\(delay >= 0 ? String(format: "%.2f", delay) : "-")" + "(\(dDelay >= 0 ? "+" : "")\(String(format: "%.2f", dDelay))) " diff --git a/Sources/AetherEngine/Renderer/SampleBufferRenderer.swift b/Sources/AetherEngine/Renderer/SampleBufferRenderer.swift index 099fdddf2..4834f088d 100644 --- a/Sources/AetherEngine/Renderer/SampleBufferRenderer.swift +++ b/Sources/AetherEngine/Renderer/SampleBufferRenderer.swift @@ -150,6 +150,8 @@ final class SampleBufferRenderer: @unchecked Sendable { /// (HEVC allows at most 16 pictures of it), so it is a timestamp discontinuity: the highest PTS /// restarts from this packet and the learned lead is left alone, instead of absorbing the jump /// into the lead and switching the look-ahead cap off for the rest of the session. + /// The gate is armed only on the dual-layer route (VOD, seekable); a forward jump holds the packet + /// until the clock reaches it, when the layer would present it anyway. /// /// The learned lead is also capped at `maxLearnedLead`: a backward step inside the 16-picture /// window that is still a glitch (a 12-frame one, say) would otherwise raise the lead for the whole @@ -347,6 +349,20 @@ final class SampleBufferRenderer: @unchecked Sendable { return _outOfOrderFramesDropped } + /// Frames the arrival-order path discarded for sitting below the seek threshold: the pictures of the + /// GOP the seek landed in that precede the target. A decision rather than a loss, so it stays out of + /// `lostBeforeLayer`, but counted so a seek that discards more than its GOP is visible. + /// Guarded by `reorderLock`. + private var _arrivalSkippedFramesDropped = 0 + /// Whether this seek's first skipped frame has been logged. Re-armed whenever the threshold changes + /// or is cleared, so the line fires once per seek rather than once per renderer. Guarded by `reorderLock`. + private var arrivalSkipLogged = false + var arrivalSkippedFramesDropped: Int { + reorderLock.lock() + defer { reorderLock.unlock() } + return _arrivalSkippedFramesDropped + } + /// #298: frames refused at the enqueue gate for carrying an unschedulable PTS. Guarded by `reorderLock`. private var _untimedFramesDropped = 0 var untimedFramesDropped: Int { @@ -429,6 +445,9 @@ final class SampleBufferRenderer: @unchecked Sendable { /// timestamps plus failed sample-buffer builds. A gap between the decoder's count and /// `handedOver` that this does not account for is a post-seek skip. let lostBeforeLayer: Int + /// Cumulative frames the arrival-order path discarded below the seek threshold. A decision + /// rather than a loss, so it is not part of `lostBeforeLayer`. + let arrivalSkipped: Int /// Shortest and longest gap between consecutive handed-over timestamps over the interval, /// nil when fewer than two frames were handed over. Source axis, seconds. let minDeltaSeconds: Double? @@ -446,6 +465,7 @@ final class SampleBufferRenderer: @unchecked Sendable { _maxHandedDeltaSeconds = -.infinity return Cadence(handedOver: enqueueCount, lostBeforeLayer: _untimedFramesDropped + _sampleBuildFailures + _outOfOrderFramesDropped, + arrivalSkipped: _arrivalSkippedFramesDropped, minDeltaSeconds: minD, maxDeltaSeconds: maxD) } @@ -542,6 +562,8 @@ final class SampleBufferRenderer: @unchecked Sendable { // the lowest one to come, so the floor starts here rather than at that frame. if arrivalFloor != nil, let time, time.isNumeric { arrivalFloor?.reset(start: CMTimeGetSeconds(time)) + // A seek sets the same threshold again after the landing; only a new target is a new seek. + if arrivalSkipBelow != CMTimeGetSeconds(time) { arrivalSkipLogged = false } arrivalSkipBelow = CMTimeGetSeconds(time) } reorderLock.unlock() @@ -576,7 +598,21 @@ final class SampleBufferRenderer: @unchecked Sendable { if let threshold = skipUntilPTS { if CMTimeCompare(pts, threshold) < 0 { + // On the arrival-order path this is the seek's own discard: the count and the once-per-seek + // line cover it as they cover the frames `handOverOnArrival` refuses below the threshold. + var logFirst = false + if arrivalFloor != nil { + _arrivalSkippedFramesDropped += 1 + logFirst = !arrivalSkipLogged + arrivalSkipLogged = true + } reorderLock.unlock() + if logFirst { + EngineLog.emit( + "[Renderer] frame at \(String(format: "%.3f", CMTimeGetSeconds(pts)))s is below the seek threshold " + + "\(String(format: "%.3f", CMTimeGetSeconds(threshold)))s; skipping until the first frame at or past it", + category: .swPlayback) + } return } skipUntilPTS = nil @@ -634,7 +670,16 @@ final class SampleBufferRenderer: @unchecked Sendable { private func handOverOnArrival(pixelBuffer: CVPixelBuffer, pts: CMTime, ptsSeconds: Double, hdr10PlusData: Data?) { if let below = arrivalSkipBelow, ptsSeconds < below { + _arrivalSkippedFramesDropped += 1 + let logFirst = !arrivalSkipLogged + arrivalSkipLogged = true reorderLock.unlock() + if logFirst { + EngineLog.emit( + "[Renderer] frame at \(String(format: "%.3f", ptsSeconds))s is below the seek threshold " + + "\(String(format: "%.3f", below))s; skipping until the first frame at or past it", + category: .swPlayback) + } return } // #303: the newest timestamp admitted, as on the reorder path. @@ -701,6 +746,7 @@ final class SampleBufferRenderer: @unchecked Sendable { // The renderer's own flush below also drops the expectation it was given. arrivalFloor?.reset() arrivalSkipBelow = nil + arrivalSkipLogged = false // #303: nothing is held any more, so the frontier is not a frontier. Left standing, a // backward seek would keep reporting the pre-seek timestamp and read as a cushion of // however far the seek travelled. diff --git a/Sources/AetherEngine/Video/AetherEngine+FelProbe.swift b/Sources/AetherEngine/Video/AetherEngine+FelProbe.swift index 466258fb3..378c0f55f 100644 --- a/Sources/AetherEngine/Video/AetherEngine+FelProbe.swift +++ b/Sources/AetherEngine/Video/AetherEngine+FelProbe.swift @@ -132,11 +132,17 @@ extension AetherEngine { let stepY = max(1, (h / 2) / 16) let startX = (w / 4) let startY = (h / 4) + // The chroma sample under a luma sample, whatever the chroma format: 4:2:0 halves both + // axes, 4:2:2 only the width, 4:4:4 neither. + let cw = CVPixelBufferGetWidthOfPlane(pixelBuffer, 1) + let ch = CVPixelBufferGetHeightOfPlane(pixelBuffer, 1) for sy in stride(from: startY, to: startY + h / 2, by: stepY) { for sx in stride(from: startX, to: startX + w / 2, by: stepX) { let lumaVal = Double(y16[sy * yStride + sx] >> 6) - let cbVal = Double(uv16[(sy / 2) * uvStride + (sx / 2) * 2] >> 6) - let crVal = Double(uv16[(sy / 2) * uvStride + (sx / 2) * 2 + 1] >> 6) + let cx = sx * cw / w + let cy = sy * ch / h + let cbVal = Double(uv16[cy * uvStride + cx * 2] >> 6) + let crVal = Double(uv16[cy * uvStride + cx * 2 + 1] >> 6) accum.sumLuma += lumaVal accum.sumCb += cbVal diff --git a/Sources/AetherEngine/Video/DoviRpuConverter.swift b/Sources/AetherEngine/Video/DoviRpuConverter.swift index 02959628d..7399f8d1f 100644 --- a/Sources/AetherEngine/Video/DoviRpuConverter.swift +++ b/Sources/AetherEngine/Video/DoviRpuConverter.swift @@ -31,6 +31,11 @@ public enum DoviRpuConverter { let convertedRpu: [UInt8]? #if DualLayerDolbyVision let rpuParams: DoviMetalParams? + /// The same RPU in Profile 8.1 with its luma/chroma mapping kept: what a frame presented as its + /// raw base layer carries (a frame released without its enhancement layer), so the display side + /// does the reshaping the composer did not. Set whenever `convertedRpu` is and the RPU was + /// parsed for the dual-layer route; nil otherwise. + let baseLayerRpu: [UInt8]? #endif let isFEL: Bool let isMEL: Bool @@ -42,11 +47,12 @@ public enum DoviRpuConverter { let disableResidual: Bool #if DualLayerDolbyVision - init(elPacket: UnsafeMutablePointer?, rpuData: [UInt8]?, convertedRpu: [UInt8]? = nil, rpuParams: DoviMetalParams? = nil, isFEL: Bool, isMEL: Bool = false, success: Bool, usesPreviousMapping: Bool = false, disableResidual: Bool = false) { + init(elPacket: UnsafeMutablePointer?, rpuData: [UInt8]?, convertedRpu: [UInt8]? = nil, baseLayerRpu: [UInt8]? = nil, rpuParams: DoviMetalParams? = nil, isFEL: Bool, isMEL: Bool = false, success: Bool, usesPreviousMapping: Bool = false, disableResidual: Bool = false) { self.rpuParams = rpuParams self.elPacket = elPacket self.rpuData = rpuData self.convertedRpu = convertedRpu + self.baseLayerRpu = baseLayerRpu self.isFEL = isFEL self.isMEL = isMEL self.success = success @@ -140,10 +146,44 @@ public enum DoviRpuConverter { /// picture is finished. Mode 2 sets both curves to no-op (and drops the EL/NLQ part) while keeping /// L1/L2 and the rest of the display metadata; an RPU that kept the mapping would make the display /// side reshape a second time. `DoviComposedFrameRpuTests` pins this on the RPU attached to composed frames. - static func convertedUnspec62(_ rpu: OpaquePointer) -> UnsafePointer? { + /// + /// `removeMapping` makes the no-op curves certain for the dual-layer route's composed frames. Mode 2 sets + /// them to no-op only when the RPU's own `el_type` is FEL, and that comes from the NLQ block, which is + /// absent when `disable_residual_flag` is set: such an RPU keeps its curves through mode 2, and the display + /// would reshape a picture the composer already reshaped. The remux route leaves it off, because the frames + /// it writes are the raw base layer, which the display does have to reshape. + /// Only the `DualLayerDolbyVision` trait builds the removal: without it the parameter is ignored and the call is never made. + static func convertedUnspec62(_ rpu: OpaquePointer, removeMapping: Bool = false) -> UnsafePointer? { + guard dovi_convert_rpu_with_mode(rpu, 2) == 0 else { return nil } + #if DualLayerDolbyVision + if removeMapping { + guard dovi_rpu_remove_mapping(rpu) == 0 else { return nil } + } + #endif + return dovi_write_unspec62_nalu(rpu) + } + + #if DualLayerDolbyVision + /// The Profile 8.1 form of the RPU in `nal` that keeps its luma/chroma mapping, as an unspec62 NAL, or nil + /// when libdovi refuses a step. This is what a frame shown as its raw base layer carries: nothing composed + /// it, so the display side has to reshape it, which the mode 2 form (`convertedUnspec62`, both curves set to + /// no-op) would stop it doing. Parses `nal` afresh, because the parsed RPU the caller holds has already been + /// converted to the other form. + /// + /// Mode 2 removes the mapping only when the RPU's own `el_type` is FEL, so a FEL RPU goes through mode 1 + /// first: its NLQ becomes MEL's, the recomputed `el_type` is MEL, and mode 2 then drops the EL part and + /// leaves the curves alone. Mode 4 ("preserving mapping") is not used: on the FEL fixture it rewrites the + /// luma curve into a different one (`DoviComposedFrameRpuTests` compares the curves with the source). + static func mappingPreservedUnspec62(_ nal: UnsafePointer, _ len: Int, isFEL: Bool) -> UnsafePointer? { + guard let rpu = DoviRpuGuard.parse(nal, len) else { return nil } + defer { dovi_rpu_free(rpu) } + if isFEL { + guard dovi_convert_rpu_with_mode(rpu, 1) == 0 else { return nil } + } guard dovi_convert_rpu_with_mode(rpu, 2) == 0 else { return nil } return dovi_write_unspec62_nalu(rpu) } + #endif /// Splits a Profile 7 packet into its layers in one walk. /// @@ -179,6 +219,7 @@ public enum DoviRpuConverter { var convertedRpuBytes: [UInt8]? #if DualLayerDolbyVision var rpuParams: DoviMetalParams? + var baseLayerRpuBytes: [UInt8]? #endif var converted = false var isFEL = false @@ -225,7 +266,12 @@ public enum DoviRpuConverter { // not flip the stream classification. dovi_rpu_free_header(hdr) } - guard let out = Self.convertedUnspec62(rpu) else { + #if DualLayerDolbyVision + let composedForm = keepRpuBytes + #else + let composedForm = false + #endif + guard let out = Self.convertedUnspec62(rpu, removeMapping: composedForm) else { degraded = true return } @@ -234,6 +280,17 @@ public enum DoviRpuConverter { degraded = true return } + #if DualLayerDolbyVision + // Parsed again for the mapping-preserved form: the RPU in hand is converted to mode 2 now. + // A refusal leaves the frame with no such form, and a bypass then falls back to the mode 2 one. + var thisBaseLayer: [UInt8]? + if keepRpuBytes, let b = Self.mappingPreservedUnspec62(nal, len, isFEL: thisFEL) { + defer { dovi_data_free(b) } + if let d = b.pointee.data, b.pointee.len > 0 { + thisBaseLayer = [UInt8](UnsafeBufferPointer(start: d, count: b.pointee.len)) + } + } + #endif rewritten.append(out) if !useCustomBlockSource { pieces.append(Piece(base: outData, count: out.pointee.len)) @@ -244,6 +301,7 @@ public enum DoviRpuConverter { convertedRpuBytes = keepRpuBytes ? [UInt8](UnsafeBufferPointer(start: outData, count: out.pointee.len)) : nil #if DualLayerDolbyVision rpuParams = thisParams + baseLayerRpuBytes = thisBaseLayer #endif usesPrevious = thisPrevious disableResidual = thisDisableResidual @@ -313,6 +371,7 @@ public enum DoviRpuConverter { elPacket: producedElPacket, rpuData: rawRpuBytes, convertedRpu: convertedRpuBytes, + baseLayerRpu: baseLayerRpuBytes, rpuParams: rpuParams, isFEL: isFEL && !isMEL, isMEL: isMEL, diff --git a/Sources/AetherEngine/Video/Metal/DoviFelChromaFormat.swift b/Sources/AetherEngine/Video/Metal/DoviFelChromaFormat.swift new file mode 100644 index 000000000..e54c7d6ff --- /dev/null +++ b/Sources/AetherEngine/Video/Metal/DoviFelChromaFormat.swift @@ -0,0 +1,46 @@ +#if DualLayerDolbyVision +import CoreVideo +import Foundation + +/// The chroma sampling of a composed Dolby Vision frame. +/// +/// The composition is defined on the 4:2:0 sites, where the base layer's chroma sits: the luma it reads +/// is aligned to those sites. So 4:2:0 is the composer's native output, and 4:2:2 and 4:4:4 are that +/// composition stepped up by co-sited means (a copy on a site, the mean of the sites either side between +/// them). 4:2:0 is the default and what playback outputs. The other two are chosen only by the +/// `AETHER_FEL_CHROMA` experiment switch, which playback reads too, so set it deliberately. +enum DoviFelChromaFormat: String, CaseIterable, Sendable { + case yuv420 = "420" + case yuv422 = "422" + case yuv444 = "444" + + /// The 10-bit biplanar video-range layout the composed picture is written in. + var pixelFormat: OSType { + switch self { + case .yuv420: return kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange + case .yuv422: return kCVPixelFormatType_422YpCbCr10BiPlanarVideoRange + case .yuv444: return kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange + } + } + + /// The chroma plane's size for a luma plane of `width` x `height`. Odd sizes round up. + func chromaSize(width: Int, height: Int) -> (width: Int, height: Int) { + switch self { + case .yuv420: return ((width + 1) / 2, (height + 1) / 2) + case .yuv422: return ((width + 1) / 2, height) + case .yuv444: return (width, height) + } + } + + /// The format an `AETHER_FEL_CHROMA` value names (`420`, `422` or `444`). Anything else, and no value, + /// is 4:2:0. + static func from(environmentValue: String?) -> DoviFelChromaFormat { + environmentValue.flatMap { DoviFelChromaFormat(rawValue: $0.trimmingCharacters(in: .whitespaces)) } ?? .yuv420 + } + + /// The format a composer starts with: `AETHER_FEL_CHROMA` when set, 4:2:0 otherwise. + static var fromEnvironment: DoviFelChromaFormat { + from(environmentValue: ProcessInfo.processInfo.environment["AETHER_FEL_CHROMA"]) + } +} +#endif diff --git a/Sources/AetherEngine/Video/Metal/DoviFelComposer.swift b/Sources/AetherEngine/Video/Metal/DoviFelComposer.swift index e4aa16b46..7e0dca2d9 100644 --- a/Sources/AetherEngine/Video/Metal/DoviFelComposer.swift +++ b/Sources/AetherEngine/Video/Metal/DoviFelComposer.swift @@ -37,6 +37,7 @@ final class DoviFelComposer: @unchecked Sendable { private var pixelBufferPool: CVPixelBufferPool? private var poolWidth: Int = 0 private var poolHeight: Int = 0 + private var poolFormat: DoviFelChromaFormat = .yuv420 private var poolBuffers: [WeakPoolBuffer] = [] private var poolBufferHighWaterMark = 0 /// Set by ``releaseOutputPool()``. A composition arriving after teardown (a VideoToolbox callback still in @@ -44,6 +45,21 @@ final class DoviFelComposer: @unchecked Sendable { private var tornDown = false private let lock = NSLock() + /// Whether playback composes into the decoded base-layer buffer instead of a buffer from the output pool + /// (see ``canComposeInPlace(baseBuffer:params:)``). On by default; `AETHER_FEL_INPLACE=0` in the + /// environment turns it off for an A/B run, and tests set it directly. Guarded by `lock`. + private var inPlaceEnabled = ProcessInfo.processInfo.environment["AETHER_FEL_INPLACE"] != "0" + /// Base-layer buffers a composition is currently writing into. A buffer in here is not composed again + /// and not handed to anything else until its command buffer retires. Guarded by `lock`. + private var inPlaceInFlight = Set() + /// Frames composed in place since creation, and frames lost to a command buffer that failed after it had + /// started writing into the base-layer buffer. Guarded by `lock`. + private var inPlaceComposedCount = 0 + private var inPlaceLostCount = 0 + /// The chroma sampling of composed frames (see ``DoviFelChromaFormat``). Starts from `AETHER_FEL_CHROMA`, + /// 4:2:0 when unset; tests set it directly. Guarded by `lock`. + private var chromaFormatStorage = DoviFelChromaFormat.fromEnvironment + private final class WeakPoolBuffer { weak var value: CVPixelBuffer? init(_ value: CVPixelBuffer) { self.value = value } @@ -116,18 +132,33 @@ final class DoviFelComposer: @unchecked Sendable { final class SharedPipelines: @unchecked Sendable { let lumaPipeline: MTLComputePipelineState let chromaPipeline: MTLComputePipelineState + let chroma422Pipeline: MTLComputePipelineState + let chroma444Pipeline: MTLComputePipelineState let layoutProbePipeline: MTLComputePipelineState? init( luma: MTLComputePipelineState, chroma: MTLComputePipelineState, + chroma422: MTLComputePipelineState, + chroma444: MTLComputePipelineState, probe: MTLComputePipelineState? ) { self.lumaPipeline = luma self.chromaPipeline = chroma + self.chroma422Pipeline = chroma422 + self.chroma444Pipeline = chroma444 self.layoutProbePipeline = probe } + /// The chroma kernel for `format`. + func pipeline(for format: DoviFelChromaFormat) -> MTLComputePipelineState { + switch format { + case .yuv420: return chromaPipeline + case .yuv422: return chroma422Pipeline + case .yuv444: return chroma444Pipeline + } + } + /// Per-device results, failures included: a shader that does not compile will not compile on the next /// frame either, so the failure is kept and every later call fails fast instead of recompiling. final class Cache: @unchecked Sendable { @@ -167,11 +198,15 @@ final class DoviFelComposer: @unchecked Sendable { static func compile(device: MTLDevice, source: String) throws -> SharedPipelines { let library = try device.makeLibrary(source: source, options: nil) guard let lumaFn = library.makeFunction(name: "dovi_compose_luma"), - let chromaFn = library.makeFunction(name: "dovi_compose_chroma") else { + let chromaFn = library.makeFunction(name: "dovi_compose_chroma"), + let chroma422Fn = library.makeFunction(name: "dovi_compose_chroma_422"), + let chroma444Fn = library.makeFunction(name: "dovi_compose_chroma_444") else { throw ComposerError.missingKernelFunction } let luma = try device.makeComputePipelineState(function: lumaFn) let chroma = try device.makeComputePipelineState(function: chromaFn) + let chroma422 = try device.makeComputePipelineState(function: chroma422Fn) + let chroma444 = try device.makeComputePipelineState(function: chroma444Fn) let probe: MTLComputePipelineState? if let probeFn = library.makeFunction(name: "dovi_layout_probe") { probe = try? device.makeComputePipelineState(function: probeFn) @@ -179,7 +214,7 @@ final class DoviFelComposer: @unchecked Sendable { probe = nil } - return SharedPipelines(luma: luma, chroma: chroma, probe: probe) + return SharedPipelines(luma: luma, chroma: chroma, chroma422: chroma422, chroma444: chroma444, probe: probe) } #if DEBUG @@ -250,7 +285,7 @@ final class DoviFelComposer: @unchecked Sendable { // MARK: - Pixel Buffer Pool Management - private func ensurePool(width: Int, height: Int) -> CVPixelBufferPool? { + private func ensurePool(width: Int, height: Int, format: DoviFelChromaFormat) -> CVPixelBufferPool? { lock.lock() defer { lock.unlock() } @@ -258,7 +293,7 @@ final class DoviFelComposer: @unchecked Sendable { // earlier look and this point cannot leave a fresh pool behind that nothing will release. guard !tornDown else { return nil } - if let pool = pixelBufferPool, poolWidth == width, poolHeight == height { + if let pool = pixelBufferPool, poolWidth == width, poolHeight == height, poolFormat == format { return pool } @@ -269,7 +304,7 @@ final class DoviFelComposer: @unchecked Sendable { ] let pixelBufferAttributes: [CFString: Any] = [ - kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, + kCVPixelBufferPixelFormatTypeKey: format.pixelFormat, kCVPixelBufferWidthKey: width, kCVPixelBufferHeightKey: height, kCVPixelBufferIOSurfacePropertiesKey: [:] as [String: Any], @@ -291,6 +326,7 @@ final class DoviFelComposer: @unchecked Sendable { self.pixelBufferPool = pool self.poolWidth = width self.poolHeight = height + self.poolFormat = format poolBuffers.removeAll() poolBufferHighWaterMark = 0 return pool @@ -308,8 +344,8 @@ final class DoviFelComposer: @unchecked Sendable { } #if DEBUG - func ensurePoolForTesting(width: Int, height: Int) -> CVPixelBufferPool? { - ensurePool(width: width, height: height) + func ensurePoolForTesting(width: Int, height: Int, format: DoviFelChromaFormat = .yuv420) -> CVPixelBufferPool? { + ensurePool(width: width, height: height, format: format) } /// One allocation from the output pool: `reserve: false` stops at the threshold, `true` draws on the reserve @@ -502,6 +538,21 @@ final class DoviFelComposer: @unchecked Sendable { params: DoviMetalParams, frameIndex: UInt32 = 0, completion: @escaping @Sendable (CVPixelBuffer?) -> Void + ) { + startComposeAsync(baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, params: params, + frameIndex: frameIndex, inPlace: false) { buffer, _ in completion(buffer) } + } + + /// The shared body of the two asynchronous entry points. `finish` gets the composed buffer, or nil with + /// `corrupt` telling whether the base-layer buffer was already being written when the frame failed (only + /// ever true in place, where the destination is the base-layer buffer itself). + private func startComposeAsync( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + inPlace: Bool, + finish completion: @escaping @Sendable (CVPixelBuffer?, _ corrupt: Bool) -> Void ) { let signpostID = OSSignpostID(log: Self.signpostLog) os_signpost(.begin, log: Self.signpostLog, name: "Compose", signpostID: signpostID, @@ -509,10 +560,12 @@ final class DoviFelComposer: @unchecked Sendable { guard let work = encodeCompose(baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, params: params, - frameIndex: frameIndex) else { + frameIndex: frameIndex, + inPlace: inPlace) else { os_signpost(.end, log: Self.signpostLog, name: "Compose", signpostID: signpostID, "encode-failed") + if inPlace { endInPlace(baseBuffer, composed: false, started: false) } releaseInFlightSlot() - completion(nil) + completion(nil, false) return } // `textures` holds the CVMetalTextures; the source buffers are held @@ -594,9 +647,11 @@ final class DoviFelComposer: @unchecked Sendable { } composer?.recordGPUTime(ms: gpuMs) os_signpost(.end, log: Self.signpostLog, name: "Compose", signpostID: signpostID) - // A command buffer that errored leaves the destination partly written: deliver a failure, - // so the caller falls back to the base layer instead of showing garbage. - completion(errorDescription == nil ? frame.destination : nil) + if inPlace { composer?.endInPlace(frame.destination, composed: errorDescription == nil, started: true) } + // A command buffer that errored leaves the destination partly written. With a pool buffer the + // caller falls back to the base layer instead of showing garbage. In place the destination + // is the base layer, so there is nothing clean to fall back to and `corrupt` says so. + completion(errorDescription == nil ? frame.destination : nil, errorDescription != nil && inPlace) frame.release() } } @@ -715,13 +770,17 @@ final class DoviFelComposer: @unchecked Sendable { let paramSlot: Int } - /// Everything up to (not including) `commit`: pool rental, texture binding, + /// Everything up to (not including) `commit`: pool rental (or, in place, none), texture binding, /// parameter upload and the two dispatches. + /// + /// `inPlace` writes the result into `baseBuffer` itself. Nothing is written by this function (the GPU + /// writes only after the caller commits), so a nil return always means the base-layer buffer is untouched. private func encodeCompose( baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, params: DoviMetalParams, - frameIndex: UInt32 + frameIndex: UInt32, + inPlace: Bool = false ) -> EncodedFrame? { let blW = CVPixelBufferGetWidth(baseBuffer) let blH = CVPixelBufferGetHeight(baseBuffer) @@ -729,52 +788,60 @@ final class DoviFelComposer: @unchecked Sendable { let elH = CVPixelBufferGetHeight(enhancementBuffer) guard blW > 0, blH > 0, elW > 0, elH > 0 else { return nil } - guard let pool = ensurePool(width: blW, height: blH) else { return nil } - - // A base-layer frame is NOT close to the composed one on real titles: measured on the three - // real FEL files its 10-bit luma differs by a mean of ~8 codes (p99 up to 230) on two and ~79 - // on clip C (`fallbackFrameDelta`), a visible brightness step. So when the threshold - // refuses, take a buffer from the reserve above it rather than wait. Only with the reserve - // spent too, wait briefly, but never long: on device a 300 ms wait stalled the - // VideoToolbox callback, then the demux loop, starving audio into 7 rebuffers in a minute. - var outBuffer: CVPixelBuffer? - var poolStatus = kCVReturnError - for attempt in 0.. CVPixelBuffer? { + // A base-layer frame is NOT close to the composed one on real titles: measured on the three + // real FEL files its 10-bit luma differs by a mean of ~8 codes (p99 up to 230) on two and ~79 + // on clip C (`fallbackFrameDelta`), a visible brightness step. So when the threshold + // refuses, take a buffer from the reserve above it rather than wait. Only with the reserve + // spent too, wait briefly, but never long: on device a 300 ms wait stalled the + // VideoToolbox callback, then the demux loop, starving audio into 7 rebuffers in a minute. + var outBuffer: CVPixelBuffer? + var poolStatus = kCVReturnError + for attempt in 0.. InPlaceRefusal? { + lock.lock() + let enabled = inPlaceEnabled + let torn = tornDown + let format = chromaFormatStorage + lock.unlock() + if !enabled { return .switchedOff } + if torn { return .tornDown } + if format != .yuv420 { return .outputChroma } + return Self.inPlaceRefusal(baseBuffer: baseBuffer, params: params) + } + + /// The buffer-and-frame part of ``inPlaceDecision(baseBuffer:params:)``, without composer state. + static func inPlaceRefusal(baseBuffer: CVPixelBuffer, params: DoviMetalParams) -> InPlaceRefusal? { + guard CVPixelBufferGetIOSurface(baseBuffer) != nil else { return .notIOSurfaceBacked } + guard CVPixelBufferGetPixelFormatType(baseBuffer) == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange else { + return .notX420 + } + let w = CVPixelBufferGetWidth(baseBuffer) + let h = CVPixelBufferGetHeight(baseBuffer) + guard w > 0, h > 0, w % 2 == 0, h % 2 == 0, + CVPixelBufferGetPlaneCount(baseBuffer) == 2, + CVPixelBufferGetWidthOfPlane(baseBuffer, 0) == w, CVPixelBufferGetHeightOfPlane(baseBuffer, 0) == h, + CVPixelBufferGetWidthOfPlane(baseBuffer, 1) == w / 2, CVPixelBufferGetHeightOfPlane(baseBuffer, 1) == h / 2 + else { return .planeLayout } + if params.flags.contains(.lumaMmr) { return .lumaMmr } + return nil + } + + /// True when ``composeInPlaceAsyncWithAcquiredSlot(baseBuffer:enhancementBuffer:params:frameIndex:completion:)`` + /// may be used for this frame. + func canComposeInPlace(baseBuffer: CVPixelBuffer, params: DoviMetalParams) -> Bool { + inPlaceDecision(baseBuffer: baseBuffer, params: params) == nil + } + + /// What an in-place composition did to the base-layer buffer. + enum InPlaceResult: Sendable, Equatable { + /// The base-layer buffer now holds the composed frame. + case composed + /// The frame could not be set up and nothing was written: the buffer still holds the base layer, which + /// is the same fallback a failed pool composition gets. + case baseIntact + /// The GPU started writing and then failed: the buffer holds a mix of base layer and composed pixels. It + /// must not be shown; the frame is dropped. A second request for a buffer that is already being + /// composed in place gets this too: it is dropped and counted as lost, not sent to the pool path + /// (a pool compose would read pixels the first composition is still writing). + case baseCorrupted + } + + /// Composes into `baseBuffer` itself, assuming the caller holds a slot from ``acquireSlot()``. The caller + /// must have asked ``canComposeInPlace(baseBuffer:params:)`` first. + /// + /// The base-layer buffer is exclusive to this composition from here until `completion` runs: the + /// decoder hands it over when it pairs the frame and keeps no other reference that reads pixels, + /// the buffer is delivered to the renderer only from `completion`, and the composer refuses a second + /// composition of the same buffer while the first is outstanding. A frame is never composed twice: + /// nothing retries after a failure, because the failed buffer is dropped. + func composeInPlaceAsyncWithAcquiredSlot( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32 = 0, + completion: @escaping @Sendable (InPlaceResult) -> Void + ) { + guard beginInPlace(baseBuffer) else { + EngineLog.emit("[DoviFelComposer] in-place compose refused: the buffer is already being composed; frame dropped", category: .swPlayback) + releaseInFlightSlot() + completion(.baseCorrupted) + return + } + startComposeAsync(baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, params: params, + frameIndex: frameIndex, inPlace: true) { buffer, corrupt in + completion(buffer != nil ? .composed : (corrupt ? .baseCorrupted : .baseIntact)) + } + } + + /// Claims `buffer` for an in-place composition; false when one is already outstanding on it. + private func beginInPlace(_ buffer: CVPixelBuffer) -> Bool { + lock.lock() + defer { lock.unlock() } + return inPlaceInFlight.insert(ObjectIdentifier(buffer)).inserted + } + + /// Releases the claim. `started` is whether the command buffer was committed (so the buffer may have been + /// written to); a failure after that is a lost frame. + private func endInPlace(_ buffer: CVPixelBuffer, composed: Bool, started: Bool) { + lock.lock() + inPlaceInFlight.remove(ObjectIdentifier(buffer)) + if composed { + inPlaceComposedCount += 1 + } else if started { + inPlaceLostCount += 1 + } + lock.unlock() + } + + /// Frames composed into their base-layer buffer so far (for the 1 Hz summary). + var inPlaceComposedFrames: Int { + lock.lock(); defer { lock.unlock() } + return inPlaceComposedCount + } + + /// Frames dropped because the GPU failed after it had started writing into the base-layer buffer. + var inPlaceLostFrames: Int { + lock.lock(); defer { lock.unlock() } + return inPlaceLostCount + } + + private var isTornDown: Bool { + lock.lock(); defer { lock.unlock() } + return tornDown + } + + #if DEBUG + /// Test seam: claims a buffer the way an outstanding in-place composition does, and gives it back. + func claimInPlaceForTesting(_ buffer: CVPixelBuffer) -> Bool { beginInPlace(buffer) } + func releaseInPlaceClaimForTesting(_ buffer: CVPixelBuffer) { endInPlace(buffer, composed: false, started: false) } + #endif + + /// Turns the in-place path on or off for this composer (A/B runs and tests). From the next frame on. + var inPlaceComposeEnabled: Bool { + get { lock.lock(); defer { lock.unlock() }; return inPlaceEnabled } + set { lock.lock(); inPlaceEnabled = newValue; lock.unlock() } + } + + /// The chroma sampling composed frames are written in. Frames already in flight keep the format they + /// were started with. + var chromaFormat: DoviFelChromaFormat { + get { lock.lock(); defer { lock.unlock() }; return chromaFormatStorage } + set { lock.lock(); chromaFormatStorage = newValue; lock.unlock() } + } + + /// Whether the output pool exists. In-place playback must leave it absent. + var hasOutputPool: Bool { + lock.lock(); defer { lock.unlock() } + return pixelBufferPool != nil + } + + /// Removes the attachments that do not propagate from `buffer`. A pool buffer gets only the base layer's + /// propagating attachments (`CVBufferPropagateAttachments`), so this makes the in-place buffer carry + /// the same set. Decoder-private per-buffer notes are the kind of thing that does not propagate. + private static func removeNonPropagatingAttachments(from buffer: CVPixelBuffer) { + guard let local = CVBufferCopyAttachments(buffer, .shouldNotPropagate) as? [CFString: Any] else { return } + for key in local.keys { CVBufferRemoveAttachment(buffer, key) } + } + /// Precomputes the 1024-entry luma prediction table when luma is polynomial (dolby-vision-fel.md §5.6). private func populateLumaLut(into ptr: UnsafeMutablePointer, params: DoviMetalParams) { let poly = params.polyY @@ -1361,7 +1652,7 @@ final class DoviFelComposer: @unchecked Sendable { /// the fixed-point ABI disables the residual for that component instead of /// being clamped, while keeping the decoded shift and pivots so a probe can /// still report what the RPU said. - private static func parseNlqParams( + static func parseNlqParams( nlq: UnsafePointer?, component c: Int, label: String, @@ -1424,8 +1715,10 @@ final class DoviFelComposer: @unchecked Sendable { // vdr_in_max is a normalised ceiling in Q(coefficient_log2_denom); the // same shift that converts the residual converts the ceiling. let scaledMax = maxFp >> Int64(nlqShift) - guard scaledMax >= 0, scaledMax <= Int64(Int32.max) else { - logOnce("[DoviFelComposer] \(label) vdr_in_max \(scaledMax) out of range; dropping residual", category: .swPlayback) + // A ceiling of 0 clamps every magnitude to min(|r|, 0) = 0 under the spec's clamp, so the residual + // contributes nothing: refusing it, which drops the residual, is exact. + guard scaledMax > 0, scaledMax <= Int64(Int32.max) else { + logOnce("[DoviFelComposer] \(label) vdr_in_max \(scaledMax) zero or out of range; dropping residual", category: .swPlayback) return disabled } diff --git a/Sources/AetherEngine/Video/Metal/DoviShaderSource.swift b/Sources/AetherEngine/Video/Metal/DoviShaderSource.swift index a44aa68a0..9d57546e7 100644 --- a/Sources/AetherEngine/Video/Metal/DoviShaderSource.swift +++ b/Sources/AetherEngine/Video/Metal/DoviShaderSource.swift @@ -327,7 +327,8 @@ static inline int dovi_denormalize(float pred_norm, uint bl_max, uint vdr_scale, // The prediction is clamped to the VDR range before the residual is added, as DoViBaker does for both // the polynomial and the MMR result, so a prediction outside it cannot be pulled back in by the // residual: a negative one starts from 0, one above full scale from vdr_max. - return clamp(int(rint(pred_norm * float(bl_max) * float(vdr_scale))), 0, int(vdr_max)); + // The clamp is in float, before the conversion: an MMR result far outside the range would overflow int. + return int(rint(clamp(pred_norm * float(bl_max) * float(vdr_scale), 0.0f, float(vdr_max)))); } /* ============================================================================ @@ -498,22 +499,25 @@ kernel void dovi_compose_luma( out_y_tex.write(ushort4(ushort(out_y << 6), 0, 0, 0), gid); } +/* The composed chroma at one 4:2:0 site, before quantisation, as clamped VDR codes. The 4:2:0 kernel + writes it on its own grid; the 4:2:2 and 4:4:4 kernels take their samples from these sites, so + every chroma format is the same composition at the 4:2:0 sites, stepped up. */ +struct DoviChromaSite { + int cb; + int cr; +}; + // Chroma: both chroma planes in one pass, since an MMR curve for Cb and for Cr // reads the same three base-layer components. -kernel void dovi_compose_chroma( - texture2d bl_y_tex [[texture(0)]], - texture2d bl_uv_tex [[texture(1)]], - texture2d el_uv_tex [[texture(2)]], - texture2d out_uv_tex [[texture(3)]], - constant struct DoviMetalParams& params [[buffer(0)]], - uint2 gid [[thread_position_in_grid]]) +static inline DoviChromaSite dovi_chroma_site( + uint2 site, + texture2d bl_y_tex, + texture2d bl_uv_tex, + texture2d el_uv_tex, + constant struct DoviMetalParams ¶ms) { - uint uv_width = params.bl_width / 2u; - uint uv_height = params.bl_height / 2u; - - if (gid.x >= uv_width || gid.y >= uv_height) { - return; - } + uint uv_width = (params.bl_width + 1u) / 2u; + uint uv_height = (params.bl_height + 1u) / 2u; // Base-layer luma at the chroma site, for the cross-component terms: [1 2 1] // around the even column in each of the cell's two rows, then the rows @@ -521,12 +525,12 @@ kernel void dovi_compose_chroma( // Dolby-encoded streams agree with it: on one with order-3 MMR chroma the // coded residual matches it at about half the error of the plain vertical // 2-tap this kernel used before. - uint lx = min(gid.x * 2u, params.bl_width - 1u); + uint lx = min(site.x * 2u, params.bl_width - 1u); uint lxl = lx > 0u ? lx - 1u : 0u; uint lxr = min(lx + 1u, params.bl_width - 1u); int bl_y = 0; for (uint r = 0u; r < 2u; ++r) { - uint ly = min(gid.y * 2u + r, params.bl_height - 1u); + uint ly = min(site.y * 2u + r, params.bl_height - 1u); int row = int(bl_y_tex.read(uint2(lxl, ly)).r >> 6) + 2 * int(bl_y_tex.read(uint2(lx, ly)).r >> 6) + int(bl_y_tex.read(uint2(lxr, ly)).r >> 6); @@ -534,7 +538,7 @@ kernel void dovi_compose_chroma( } bl_y = (bl_y + 1) >> 1; - ushort4 bl_uv = bl_uv_tex.read(gid); + ushort4 bl_uv = bl_uv_tex.read(site); int bl_cb = int(bl_uv.r >> 6); int bl_cr = int(bl_uv.g >> 6); @@ -564,8 +568,8 @@ kernel void dovi_compose_chroma( // the odd ones half way between two texels. This is DoViBaker's phase (`upsampleChromaEven` // copies, `upsampleChromaOdd` interpolates at 0.5); a centre-aligned fetch puts every // sample a quarter of an EL chroma texel up and to the left. Same-resolution EL is unchanged. - float2 el_uv_size = float2(params.el_width / 2u, params.el_height / 2u); - float2 uv = float2(gid) / float2(uv_width, uv_height) + 0.5f / el_uv_size; + float2 el_uv_size = float2((params.el_width + 1u) / 2u, (params.el_height + 1u) / 2u); + float2 uv = float2(site) / float2(uv_width, uv_height) + 0.5f / el_uv_size; float2 el = dovi_sample_el_2d(el_uv_tex, s, uv, el_uv_size) * dovi_unorm_to_code; if (cb_active) { vdr_cb += dovi_eval_inv_nlq(params.nlq_cb, int(rint(el.r))); @@ -576,9 +580,19 @@ kernel void dovi_compose_chroma( } } - vdr_cb = clamp(vdr_cb, 0, int(params.vdr_max)); - vdr_cr = clamp(vdr_cr, 0, int(params.vdr_max)); + DoviChromaSite out; + out.cb = clamp(vdr_cb, 0, int(params.vdr_max)); + out.cr = clamp(vdr_cr, 0, int(params.vdr_max)); + return out; +} +static inline void dovi_write_chroma( + texture2d out_uv_tex, + uint2 gid, + int vdr_cb, + int vdr_cr, + constant struct DoviMetalParams ¶ms) +{ int out_cb = dovi_quantize(vdr_cb, params.vdr_scale, params.dither_mode, dovi_dither(gid, params.frame_index, uint2(1u, 0u)), 64, 960); int out_cr = dovi_quantize(vdr_cr, params.vdr_scale, params.dither_mode, @@ -596,6 +610,99 @@ kernel void dovi_compose_chroma( out_uv_tex.write(ushort4(ushort(out_cb << 6), ushort(out_cr << 6), 0, 0), gid); } +kernel void dovi_compose_chroma( + texture2d bl_y_tex [[texture(0)]], + texture2d bl_uv_tex [[texture(1)]], + texture2d el_uv_tex [[texture(2)]], + texture2d out_uv_tex [[texture(3)]], + constant struct DoviMetalParams& params [[buffer(0)]], + uint2 gid [[thread_position_in_grid]]) +{ + uint uv_width = (params.bl_width + 1u) / 2u; + uint uv_height = (params.bl_height + 1u) / 2u; + + if (gid.x >= uv_width || gid.y >= uv_height) { + return; + } + + DoviChromaSite site = dovi_chroma_site(gid, bl_y_tex, bl_uv_tex, el_uv_tex, params); + dovi_write_chroma(out_uv_tex, gid, site.cb, site.cr, params); +} + +// A 4:2:0 site as the output holds it: quantised and clamped to video range. The step-up kernels mean +// these, not the unclamped codes, so a site past the range does not pull its neighbours' means off +// the picture the 4:2:0 output would show. +static inline DoviChromaSite dovi_output_site( + uint2 site, + texture2d bl_y_tex, + texture2d bl_uv_tex, + texture2d el_uv_tex, + constant struct DoviMetalParams ¶ms) +{ + DoviChromaSite s = dovi_chroma_site(site, bl_y_tex, bl_uv_tex, el_uv_tex, params); + int lo = int(64u * params.vdr_scale); + int hi = int(960u * params.vdr_scale); + s.cb = clamp(s.cb, lo, hi); + s.cr = clamp(s.cr, lo, hi); + return s; +} + +// 4:2:2 has the luma's full height. A row on a 4:2:0 site is that site's composition; a row between +// two sites is their mean, the co-sited step up that the 4:2:0 phase implies (copy on the site row, +// half way between the rows either side). Rounded up, so with dither off an exact site row comes back unchanged. +kernel void dovi_compose_chroma_422( + texture2d bl_y_tex [[texture(0)]], + texture2d bl_uv_tex [[texture(1)]], + texture2d el_uv_tex [[texture(2)]], + texture2d out_uv_tex [[texture(3)]], + constant struct DoviMetalParams& params [[buffer(0)]], + uint2 gid [[thread_position_in_grid]]) +{ + uint uv_width = (params.bl_width + 1u) / 2u; + uint uv_height = (params.bl_height + 1u) / 2u; + + if (gid.x >= uv_width || gid.y >= params.bl_height) { + return; + } + + uint site_y = gid.y / 2u; + uint other_y = (gid.y & 1u) != 0u ? min(site_y + 1u, uv_height - 1u) : site_y; + DoviChromaSite a = dovi_output_site(uint2(gid.x, site_y), bl_y_tex, bl_uv_tex, el_uv_tex, params); + DoviChromaSite b = dovi_output_site(uint2(gid.x, other_y), bl_y_tex, bl_uv_tex, el_uv_tex, params); + dovi_write_chroma(out_uv_tex, gid, (a.cb + b.cb + 1) >> 1, (a.cr + b.cr + 1) >> 1, params); +} + +// 4:4:4 has the luma's full size: each sample is the mean of the 4:2:0 sites around it (one site on +// a site, two between two, four in a cell's middle), rounded once. Experimental: the reconstruction +// has no 4:4:4 form in the spec, so this only steps the 4:2:0 composition up. +kernel void dovi_compose_chroma_444( + texture2d bl_y_tex [[texture(0)]], + texture2d bl_uv_tex [[texture(1)]], + texture2d el_uv_tex [[texture(2)]], + texture2d out_uv_tex [[texture(3)]], + constant struct DoviMetalParams& params [[buffer(0)]], + uint2 gid [[thread_position_in_grid]]) +{ + uint uv_width = (params.bl_width + 1u) / 2u; + uint uv_height = (params.bl_height + 1u) / 2u; + + if (gid.x >= params.bl_width || gid.y >= params.bl_height) { + return; + } + + uint site_x = gid.x / 2u; + uint site_y = gid.y / 2u; + uint other_x = (gid.x & 1u) != 0u ? min(site_x + 1u, uv_width - 1u) : site_x; + uint other_y = (gid.y & 1u) != 0u ? min(site_y + 1u, uv_height - 1u) : site_y; + DoviChromaSite a = dovi_output_site(uint2(site_x, site_y), bl_y_tex, bl_uv_tex, el_uv_tex, params); + DoviChromaSite b = dovi_output_site(uint2(other_x, site_y), bl_y_tex, bl_uv_tex, el_uv_tex, params); + DoviChromaSite c = dovi_output_site(uint2(site_x, other_y), bl_y_tex, bl_uv_tex, el_uv_tex, params); + DoviChromaSite d = dovi_output_site(uint2(other_x, other_y), bl_y_tex, bl_uv_tex, el_uv_tex, params); + dovi_write_chroma(out_uv_tex, gid, + (a.cb + b.cb + c.cb + d.cb + 2) >> 2, + (a.cr + b.cr + c.cr + d.cr + 2) >> 2, params); +} + /* ============================================================================ Layout probe ========================================================================= */ diff --git a/Tests/AetherEngineTests/DocumentedConstantsTests.swift b/Tests/AetherEngineTests/DocumentedConstantsTests.swift index baeec4f1f..004449dc9 100644 --- a/Tests/AetherEngineTests/DocumentedConstantsTests.swift +++ b/Tests/AetherEngineTests/DocumentedConstantsTests.swift @@ -89,7 +89,7 @@ final class DocumentedConstantsTests: XCTestCase { 0.036, accuracy: 1e-9) assertDocumented("once K = 3 newer base-layer", fel) - assertDocumented("is the maximum layer skew, 3;", fel) + assertDocumented("(`maxLayerSkewFrames`) is the maximum layer", fel) assertDocumented("waiting enhancement frames capped at 10", fel) assertDocumented("At most 10 enhancement frames wait", fel) assertDocumented("fixed capacity 32", fel) diff --git a/Tests/AetherEngineTests/DoviChromaFormatTests.swift b/Tests/AetherEngineTests/DoviChromaFormatTests.swift new file mode 100644 index 000000000..08e83518a --- /dev/null +++ b/Tests/AetherEngineTests/DoviChromaFormatTests.swift @@ -0,0 +1,196 @@ +#if DualLayerDolbyVision +import CoreVideo +import Foundation +import Metal +import Testing +@testable import AetherEngine + +/// The chroma sampling switch. 4:2:0 is the composition itself; 4:2:2 and 4:4:4 are that composition stepped +/// up by co-sited means. With dither off, every 4:2:0 site they keep is the 4:2:0 output bit for bit, and the +/// samples between sites are their means. +@Suite(.offCooperativePoolOneLane, .enabled(if: MTLCreateSystemDefaultDevice() != nil, "needs a Metal device")) +struct DoviChromaFormatTests { + + typealias Fixture = DoviFelInPlaceComposeTests + + static let width = 256 + static let height = 128 + + /// One frame composed at `format` with dither off (round to nearest), so every sample is exact. + static func compose(_ composer: DoviFelComposer, format: DoviFelChromaFormat, chroma: Fixture.ChromaCurves) throws -> CVPixelBuffer { + composer.chromaFormat = format + let (bl, el) = try #require(Fixture.makeBaseAndEnhancement(width: width, height: height, seed: 3)) + let params = Fixture.params(dither: .none, residual: true, chroma: chroma) + return try #require(composer.compose(baseBuffer: bl, enhancementBuffer: el, params: params, frameIndex: 0)) + } + + /// The raw (Cb, Cr) words at chroma position (x, y), for a chroma plane `planeWidth` samples wide. + static func pair(_ words: [UInt16], planeWidth: Int, x: Int, y: Int) -> (cb: Int, cr: Int) { + let i = y * planeWidth * 2 + x * 2 + return (Int(words[i]), Int(words[i + 1])) + } + + /// One 10-bit code is 64 in the raw words. A mean of two or four quantised codes can differ from the + /// quantised mean by one code, so the between-site checks allow that much. + static let oneCode = 64 + + // MARK: - Layout + + @Test("each chroma format writes its own 10-bit biplanar layout at its own chroma size", + arguments: DoviFelChromaFormat.allCases) + func layoutFollowsFormat(format: DoviFelChromaFormat) throws { + let composer = try #require(DoviFelComposer()) + let out = try Self.compose(composer, format: format, chroma: .polynomial) + #expect(CVPixelBufferGetPixelFormatType(out) == format.pixelFormat) + #expect(CVPixelBufferGetWidthOfPlane(out, 0) == Self.width) + let size = format.chromaSize(width: Self.width, height: Self.height) + #expect(CVPixelBufferGetWidthOfPlane(out, 1) == size.width) + #expect(CVPixelBufferGetHeightOfPlane(out, 1) == size.height) + } + + @Test("odd luma sizes compose in every format, at the rounded chroma size", + arguments: DoviFelChromaFormat.allCases) + func oddLumaSizesCompose(format: DoviFelChromaFormat) throws { + let composer = try #require(DoviFelComposer()) + composer.chromaFormat = format + let (bl, el) = try #require(Fixture.makeBaseAndEnhancement(width: 255, height: 127, seed: 3)) + let params = Fixture.params(dither: .none, residual: true, chroma: .polynomial) + let out = try #require(composer.compose(baseBuffer: bl, enhancementBuffer: el, params: params, frameIndex: 0)) + let size = format.chromaSize(width: 255, height: 127) + #expect(CVPixelBufferGetWidthOfPlane(out, 0) == 255) + #expect(CVPixelBufferGetWidthOfPlane(out, 1) == size.width) + #expect(CVPixelBufferGetHeightOfPlane(out, 1) == size.height) + } + + @Test("switching back to 4:2:0 on one composer reproduces the first 4:2:0 frame") + func switchingBackReproducesFourTwoZero() throws { + let composer = try #require(DoviFelComposer()) + let first = try Self.compose(composer, format: .yuv420, chroma: .polynomial) + _ = try Self.compose(composer, format: .yuv444, chroma: .polynomial) + let again = try Self.compose(composer, format: .yuv420, chroma: .polynomial) + #expect(Fixture.visibleWords(first, plane: 0) == Fixture.visibleWords(again, plane: 0)) + #expect(Fixture.visibleWords(first, plane: 1) == Fixture.visibleWords(again, plane: 1)) + } + + @Test("odd luma sizes round the 4:2:0 chroma up, and 4:2:2 keeps the full height") + func chromaSizesRoundUp() { + #expect(DoviFelChromaFormat.yuv420.chromaSize(width: 255, height: 127) == (128, 64)) + #expect(DoviFelChromaFormat.yuv422.chromaSize(width: 255, height: 127) == (128, 127)) + #expect(DoviFelChromaFormat.yuv444.chromaSize(width: 255, height: 127) == (255, 127)) + } + + @Test("AETHER_FEL_CHROMA picks 4:2:2 or 4:4:4, and any other value keeps 4:2:0") + func environmentValueChoosesFormat() { + #expect(DoviFelChromaFormat.from(environmentValue: "422") == .yuv422) + #expect(DoviFelChromaFormat.from(environmentValue: " 444 ") == .yuv444) + #expect(DoviFelChromaFormat.from(environmentValue: "420p") == .yuv420) + #expect(DoviFelChromaFormat.from(environmentValue: nil) == .yuv420) + } + + // MARK: - Samples + + @Test("4:2:2 keeps every 4:2:0 site row bit for bit, on one composer that changes format between frames", + arguments: [Fixture.ChromaCurves.polynomial, Fixture.ChromaCurves.mmrTwoPieces]) + func fourTwoTwoKeepsSiteRows(chroma: Fixture.ChromaCurves) throws { + let composer = try #require(DoviFelComposer()) + let site = try Self.compose(composer, format: .yuv420, chroma: chroma) + let wide = try Self.compose(composer, format: .yuv422, chroma: chroma) + let siteWords = Fixture.visibleWords(site, plane: 1) + let wideWords = Fixture.visibleWords(wide, plane: 1) + let cw = Self.width / 2 + for r in 0..<(Self.height / 2) { + for x in 0.. [UInt8]? in + guard let rpu = DoviRpuGuard.parse(buf.baseAddress!, buf.count) else { return nil } + defer { dovi_rpu_free(rpu) } + guard dovi_convert_rpu_with_mode(rpu, 1) == 0, dovi_convert_rpu_with_mode(rpu, 2) == 0, + let out = dovi_write_unspec62_nalu(rpu) else { return nil } + defer { dovi_data_free(out) } + return [UInt8](UnsafeBufferPointer(start: out.pointee.data, count: out.pointee.len)) + } + }() + + @Test("The residual-off fixture has the flag set, no el_type, a non-identity mapping, and L1/L2") + func noResidualFixtureShape() throws { + let nal = try #require(Self.felNoResidualNAL) + let s = try #require(Self.snapshot(ofNAL: nal)) + #expect(s.disableResidual) + #expect(s.elType == nil) + #expect(!s.hasNLQ) + #expect(!s.isIdentityMapping) + #expect(s.blocks["L1"] != nil) + #expect(s.blocks["L2"] != nil) + } + + @Test("A composed frame whose RPU has disable_residual_flag set still carries no mapping, and the same display metadata") + func noResidualRpuIsUnmappedOnComposedFrames() throws { + let nal = try #require(Self.felNoResidualNAL) + let source = try #require(Self.snapshot(ofNAL: nal)) + let (raw, attached) = try #require(Self.composedFrameRpu(source: nal)) + #expect(raw == nal) + let out = try #require(Self.snapshot(ofNAL: attached)) + Self.expectPinned(source: source, attached: out, label: "fel_orig, residual off") + } + + /// An access unit with no enhancement layer NAL (the case the decoder releases a base frame for) still + /// yields both forms of its RPU: the composed one with the mapping removed, and the one a raw base + /// layer carries, which keeps it. + @Test("An access unit without an enhancement layer yields a mapping-preserving RPU for the raw base layer and the no-op one for a composed frame", + arguments: ["fel_orig", "fel_orig, residual off"]) + func baseLayerRpuKeepsTheMapping(label: String) throws { + let nal = try #require(label == "fel_orig" ? Self.felNAL : Self.felNoResidualNAL) + let pkt = Self.avcc([Self.hevcNAL(type: 1, payload: [0xAA, 0xBB]), nal]) + defer { var p: UnsafeMutablePointer? = pkt; av_packet_free(&p) } + let r = DoviRpuConverter.separateDualLayers(pkt, extractEL: true, useCustomBlockSource: true, extractParams: true) + #expect(r.elPacket == nil, "the access unit has no enhancement layer") + let source = try #require(Self.snapshot(ofNAL: nal)) + let composed = try #require(r.convertedRpu) + let baseLayer = try #require(r.baseLayerRpu) + #expect(baseLayer.prefix(2).elementsEqual([0x7C, 0x01]), "complete unspec62 NAL") + + let c = try #require(Self.snapshot(ofNAL: composed)) + Self.expectPinned(source: source, attached: c, label: "\(label): composed form") + + let b = try #require(Self.snapshot(ofNAL: baseLayer)) + #expect(b.profile == 8, "not Profile 8") + #expect(b.carriesNoEnhancementLayer, "still carries an EL/NLQ part") + #expect(!b.isIdentityMapping, "the mapping was dropped: the display would show the base layer unreshaped") + #expect(b.curves == source.curves, "the curves changed") + #expect(b.blocks == source.blocks, "display metadata changed") + #expect(baseLayer != composed) + } + #endif } diff --git a/Tests/AetherEngineTests/DoviCpuModelTests.swift b/Tests/AetherEngineTests/DoviCpuModelTests.swift index 33e199d1d..5ebad46c2 100644 --- a/Tests/AetherEngineTests/DoviCpuModelTests.swift +++ b/Tests/AetherEngineTests/DoviCpuModelTests.swift @@ -93,7 +93,11 @@ struct DoviCpuModelTests { return s } - func denormalize(_ n: Double) -> Int { Int((n * blMax * scale).rounded(.toNearestOrEven)) } + /// The VDR-range clamp is left out on purpose so the census below still counts predictions outside + /// the range. + func denormalize(_ n: Double) -> Int { + Int((n * blMax * scale).rounded(.toNearestOrEven)) + } /// Inverse NLQ in `Int64`, reconstructing at the middle of the quantisation step. func inverseNlq(_ n: DoviNlqParams, _ el: Int) -> Int { diff --git a/Tests/AetherEngineTests/DoviCrcValidFuzzTests.swift b/Tests/AetherEngineTests/DoviCrcValidFuzzTests.swift index c8d901655..e625b1de8 100644 --- a/Tests/AetherEngineTests/DoviCrcValidFuzzTests.swift +++ b/Tests/AetherEngineTests/DoviCrcValidFuzzTests.swift @@ -3,145 +3,147 @@ import Foundation import Dovi @testable import AetherEngine -/// The existing RPU fuzz mostly dies at the CRC check. This one repairs the CRC after mutating the -/// payload (and re-applies emulation prevention), so the mutated RPU actually reaches libdovi's -/// parser, which is built panic=abort: any upstream panic here would kill the process. -@Suite(.offCooperativePoolOneLane) -struct DoviCrcValidFuzzTests { +extension HeavyDualLayerSuites { + /// The existing RPU fuzz mostly dies at the CRC check. This one repairs the CRC after mutating the + /// payload (and re-applies emulation prevention), so the mutated RPU actually reaches libdovi's + /// parser, which is built panic=abort: any upstream panic here would kill the process. + @Suite(.offCooperativePool) + struct DoviCrcValidFuzzTests { - private static let crcTable: [UInt32] = (0..<256).map { i in - var c = UInt32(i) << 24 - for _ in 0..<8 { c = c & 0x8000_0000 != 0 ? (c << 1) ^ 0x04C1_1DB7 : c << 1 } - return c - } + private static let crcTable: [UInt32] = (0..<256).map { i in + var c = UInt32(i) << 24 + for _ in 0..<8 { c = c & 0x8000_0000 != 0 ? (c << 1) ^ 0x04C1_1DB7 : c << 1 } + return c + } - static func escape(_ u: [UInt8]) -> [UInt8] { - var out = [UInt8](), zeros = 0 - for (i, b) in u.enumerated() { - if zeros >= 2 && b <= 3 && i >= 2 { out.append(3); zeros = 0 } - out.append(b) - zeros = b == 0 ? zeros + 1 : 0 + static func escape(_ u: [UInt8]) -> [UInt8] { + var out = [UInt8](), zeros = 0 + for (i, b) in u.enumerated() { + if zeros >= 2 && b <= 3 && i >= 2 { out.append(3); zeros = 0 } + out.append(b) + zeros = b == 0 ? zeros + 1 : 0 + } + return out } - return out - } - /// Mutates payload bytes of an unescaped RPU and fixes up the CRC. - static func mutateValid(_ u: [UInt8], _ rng: inout DoviFelFuzzTests.RNG) -> [UInt8] { - var b = u - let tailAt = b.count - 5 - guard tailAt > 4 else { return b } - switch rng.below(4) { - case 0: for _ in 0..<(1 + rng.below(4)) { b[3 + rng.below(tailAt - 3)] ^= UInt8(1 << rng.below(8)) } - case 1: b[3 + rng.below(tailAt - 3)] = UInt8(truncatingIfNeeded: rng.next()) - case 2: // concentrate on the header/mapping region, where the syntax branches - for _ in 0..<(1 + rng.below(3)) { b[3 + rng.below(min(40, tailAt - 3))] ^= UInt8(1 << rng.below(8)) } - default: - let start = 3 + rng.below(tailAt - 3), len = 1 + rng.below(8) - for i in start.. [UInt8] { + var b = u + let tailAt = b.count - 5 + guard tailAt > 4 else { return b } + switch rng.below(4) { + case 0: for _ in 0..<(1 + rng.below(4)) { b[3 + rng.below(tailAt - 3)] ^= UInt8(1 << rng.below(8)) } + case 1: b[3 + rng.below(tailAt - 3)] = UInt8(truncatingIfNeeded: rng.next()) + case 2: // concentrate on the header/mapping region, where the syntax branches + for _ in 0..<(1 + rng.below(3)) { b[3 + rng.below(min(40, tailAt - 3))] ^= UInt8(1 << rng.below(8)) } + default: + let start = 3 + rng.below(tailAt - 3), len = 1 + rng.below(8) + for i in start..> 24) ^ UInt32(b[i]))] } + b[tailAt] = UInt8(crc >> 24); b[tailAt + 1] = UInt8((crc >> 16) & 0xFF) + b[tailAt + 2] = UInt8((crc >> 8) & 0xFF); b[tailAt + 3] = UInt8(crc & 0xFF) + return b } - var crc: UInt32 = 0xFFFF_FFFF - for i in 3..> 24) ^ UInt32(b[i]))] } - b[tailAt] = UInt8(crc >> 24); b[tailAt + 1] = UInt8((crc >> 16) & 0xFF) - b[tailAt + 2] = UInt8((crc >> 8) & 0xFF); b[tailAt + 3] = UInt8(crc & 0xFF) - return b - } - /// Found by `crcValidMutations` (fixture 1): a CRC-valid RPU whose partition count is a 41-zero exp-Golomb - /// code. libdovi parses it and then reads a linear-interp flag (abort); the guard must follow the code past 32 zeros. - static let crcBytesReachLinearFlag = "7c011908090840613650ae20002008020080200802007f801ffc00ffc000ff00ff00000300000300d012811f6bf799ff6bf740000033fed7ee80000067fdafdd00000300cffb5fba000003019ff6bf74000003033fed7ee8000003029cdbf713e290d652833715954c126341284d15cc741520f02a42e5370f5ae1a762d5a57ca0430473c965dc73e4d29f0f297d65394bee5e94a7d40d35adc59e1a36d3f34ee2eb989b55a704d10a2f93177b914f4592097648f189d4419cd595ec49e0ecb7c041984ff7b4f97998e4d1ef509fe7b00ee182b2675ba33e73521f46cfaf3860d29087e92b30b43b2437b1006000004010004000004018000010040010000010060000040100040000072566000035ea2566f9fceb1c256644ca0000030100000300080000030008000003001c36224301860a5e308e051400000301a63e5affff000003000003000003000060207dce0151c030080002300e80c028217c58006d48008351000180580f0010010011a73442000404044129100a000003000460230024183e8000c828340ab0b02e561a80" + /// Found by `crcValidMutations` (fixture 1): a CRC-valid RPU whose partition count is a 41-zero exp-Golomb + /// code. libdovi parses it and then reads a linear-interp flag (abort); the guard must follow the code past 32 zeros. + static let crcBytesReachLinearFlag = "7c011908090840613650ae20002008020080200802007f801ffc00ffc000ff00ff00000300000300d012811f6bf799ff6bf740000033fed7ee80000067fdafdd00000300cffb5fba000003019ff6bf74000003033fed7ee8000003029cdbf713e290d652833715954c126341284d15cc741520f02a42e5370f5ae1a762d5a57ca0430473c965dc73e4d29f0f297d65394bee5e94a7d40d35adc59e1a36d3f34ee2eb989b55a704d10a2f93177b914f4592097648f189d4419cd595ec49e0ecb7c041984ff7b4f97998e4d1ef509fe7b00ee182b2675ba33e73521f46cfaf3860d29087e92b30b43b2437b1006000004010004000004018000010040010000010060000040100040000072566000035ea2566f9fceb1c256644ca0000030100000300080000030008000003001c36224301860a5e308e051400000301a63e5affff000003000003000003000060207dce0151c030080002300e80c028217c58006d48008351000180580f0010010011a73442000404044129100a000003000460230024183e8000c828340ab0b02e561a80" - @Test("the guard reads exp-Golomb codes as long as libdovi does") - func guardCoversCrcBytes() throws { - let bytes = stride(from: 0, to: Self.crcBytesReachLinearFlag.count, by: 2).map { - UInt8(Self.crcBytesReachLinearFlag.dropFirst($0).prefix(2), radix: 16)! + @Test("the guard reads exp-Golomb codes as long as libdovi does") + func guardCoversCrcBytes() throws { + let bytes = stride(from: 0, to: Self.crcBytesReachLinearFlag.count, by: 2).map { + UInt8(Self.crcBytesReachLinearFlag.dropFirst($0).prefix(2), radix: 16)! + } + #expect(bytes.withUnsafeBufferPointer { DoviRpuGuard.crcIsValid($0.baseAddress!, $0.count) }) + #expect(bytes.withUnsafeBufferPointer { DoviRpuGuard.parse($0.baseAddress!, $0.count) } == nil) } - #expect(bytes.withUnsafeBufferPointer { DoviRpuGuard.crcIsValid($0.baseAddress!, $0.count) }) - #expect(bytes.withUnsafeBufferPointer { DoviRpuGuard.parse($0.baseAddress!, $0.count) } == nil) - } - /// RPUs built in the test source (`DoviRpuGuardTests.craft`), so the fuzz runs wherever the suite does: - /// a Profile 7 FEL RPU with a two-piece luma curve and MMR chroma, a MEL one whose curves are all - /// polynomial, and a MEL one with MMR chroma. A MEL RPU keeps its mapping through the mode 2 - /// conversion, so these reach the writer's curve code; an FEL RPU's mapping is dropped there. - static let syntheticSeeds: [(name: String, nal: [UInt8])] = [ - ("synthetic FEL", DoviRpuGuardTests.craft(pieces: [[.polynomial, .polynomial], [.mmr], [.mmr]], residual: .fel)), - ("synthetic MEL, polynomial", DoviRpuGuardTests.craft(pieces: [[.polynomial], [.polynomial], [.polynomial]], residual: .mel)), - ("synthetic MEL, MMR chroma", DoviRpuGuardTests.craft(pieces: [[.polynomial], [.mmr], [.mmr]], residual: .mel)), - ] + /// RPUs built in the test source (`DoviRpuGuardTests.craft`), so the fuzz runs wherever the suite does: + /// a Profile 7 FEL RPU with a two-piece luma curve and MMR chroma, a MEL one whose curves are all + /// polynomial, and a MEL one with MMR chroma. A MEL RPU keeps its mapping through the mode 2 + /// conversion, so these reach the writer's curve code; an FEL RPU's mapping is dropped there. + static let syntheticSeeds: [(name: String, nal: [UInt8])] = [ + ("synthetic FEL", DoviRpuGuardTests.craft(pieces: [[.polynomial, .polynomial], [.mmr], [.mmr]], residual: .fel)), + ("synthetic MEL, polynomial", DoviRpuGuardTests.craft(pieces: [[.polynomial], [.polynomial], [.polynomial]], residual: .mel)), + ("synthetic MEL, MMR chroma", DoviRpuGuardTests.craft(pieces: [[.polynomial], [.mmr], [.mmr]], residual: .mel)), + ] - /// `AETHER_FUZZ_N` overrides the iteration count; the default is what a CI run can afford per seed - /// for the synthetic seeds and what an unattended soak is worth for a clip. - static func iterations(default fallback: Int) -> Int { - Int(ProcessInfo.processInfo.environment["AETHER_FUZZ_N"] ?? "") ?? fallback - } + /// `AETHER_FUZZ_N` overrides the iteration count; the default is what a CI run can afford per seed + /// for the synthetic seeds and what an unattended soak is worth for a clip. + static func iterations(default fallback: Int) -> Int { + Int(ProcessInfo.processInfo.environment["AETHER_FUZZ_N"] ?? "") ?? fallback + } - /// Mutates `nal` with the CRC repaired every time, and pushes whatever the guard lets through the - /// way production does: parse, the composer's parameter walk, then the mode 2 conversion and the - /// write (`DoviRpuConverter.convertedUnspec62`). libdovi aborts the process on a panic, so - /// getting to the end is the assertion. - static func fuzz(seed nal: [UInt8], label: String, rngSeed: UInt64, iterations: Int) throws { - try DoviFelFuzzTests.expectLibdoviAccepts(nal) - let u = try #require(nal.withUnsafeBufferPointer { DoviRpuGuard.verifiedUnescaped($0.baseAddress!, $0.count) }) - let env = ProcessInfo.processInfo.environment - var rng = DoviFelFuzzTests.RNG(state: rngSeed + (UInt64(env["AETHER_FUZZ_SEED"] ?? "") ?? 0) << 16) - var reached = 0, guarded = 0, parsed = 0, written = 0 - for _ in 0.. iterations * 3 / 4) + #expect(written > 0, "no mutant survived to the writer: the seed is not exercising it") } - print("[fuzz-crc] \(label): \(reached) CRC-valid mutants, \(guarded) rejected by guard/libdovi, \(parsed) parsed, \(written) converted and written") - #expect(reached > iterations * 3 / 4) - #expect(written > 0, "no mutant survived to the writer: the seed is not exercising it") - } - @Test("a CRC-valid mutated synthetic RPU never aborts libdovi in parse, convert or write", - .timeLimit(.minutes(5)), arguments: DoviCrcValidFuzzTests.syntheticSeeds.indices) - func crcValidMutationsOfSyntheticSeeds(index: Int) throws { - let seed = Self.syntheticSeeds[index] - try Self.fuzz(seed: seed.nal, label: seed.name, rngSeed: 0x5E0 + UInt64(index), - iterations: Self.iterations(default: 20000)) - } + @Test("a CRC-valid mutated synthetic RPU never aborts libdovi in parse, convert or write", + .timeLimit(.minutes(5)), arguments: DoviCrcValidFuzzTests.syntheticSeeds.indices) + func crcValidMutationsOfSyntheticSeeds(index: Int) throws { + let seed = Self.syntheticSeeds[index] + try Self.fuzz(seed: seed.nal, label: seed.name, rngSeed: 0x5E0 + UInt64(index), + iterations: Self.iterations(default: 20000)) + } - static func fuzzClip(_ fixture: Int) throws { - let seed = try #require(try DoviFelFuzzTests.seed(index: fixture)) - try fuzz(seed: seed.rpu, label: "fixture \(fixture)", rngSeed: 0xC4C + UInt64(fixture), - iterations: iterations(default: 20000)) - } + static func fuzzClip(_ fixture: Int) throws { + let seed = try #require(try DoviFelFuzzTests.seed(index: fixture)) + try fuzz(seed: seed.rpu, label: "fixture \(fixture)", rngSeed: 0xC4C + UInt64(fixture), + iterations: iterations(default: 20000)) + } - @Test("a CRC-valid mutated RPU from the first clip never aborts libdovi in parse, convert or write", - .enabled(if: DoviFelRealFileTests.available(DoviFelRealFileTests.fixtures[0]), DoviFelRealFileTests.needsClips), - .timeLimit(.minutes(30))) - func crcValidMutationsClip0() throws { try Self.fuzzClip(0) } + @Test("a CRC-valid mutated RPU from the first clip never aborts libdovi in parse, convert or write", + .enabled(if: DoviFelRealFileTests.available(DoviFelRealFileTests.fixtures[0]), DoviFelRealFileTests.needsClips), + .timeLimit(.minutes(30))) + func crcValidMutationsClip0() throws { try Self.fuzzClip(0) } - @Test("a CRC-valid mutated RPU from the second clip never aborts libdovi in parse, convert or write", - .enabled(if: DoviFelRealFileTests.available(DoviFelRealFileTests.fixtures[1]), DoviFelRealFileTests.needsClips), - .timeLimit(.minutes(30))) - func crcValidMutationsClip1() throws { try Self.fuzzClip(1) } + @Test("a CRC-valid mutated RPU from the second clip never aborts libdovi in parse, convert or write", + .enabled(if: DoviFelRealFileTests.available(DoviFelRealFileTests.fixtures[1]), DoviFelRealFileTests.needsClips), + .timeLimit(.minutes(30))) + func crcValidMutationsClip1() throws { try Self.fuzzClip(1) } - @Test("a CRC-valid mutated RPU from the third clip never aborts libdovi in parse, convert or write", - .enabled(if: DoviFelRealFileTests.available(DoviFelRealFileTests.fixtures[2]), DoviFelRealFileTests.needsClips), - .timeLimit(.minutes(30))) - func crcValidMutationsClip2() throws { try Self.fuzzClip(2) } + @Test("a CRC-valid mutated RPU from the third clip never aborts libdovi in parse, convert or write", + .enabled(if: DoviFelRealFileTests.available(DoviFelRealFileTests.fixtures[2]), DoviFelRealFileTests.needsClips), + .timeLimit(.minutes(30))) + func crcValidMutationsClip2() throws { try Self.fuzzClip(2) } + } } -extension DoviCrcValidFuzzTests { +extension HeavyDualLayerSuites.DoviCrcValidFuzzTests { @Test("replay a dumped RPU", .enabled(if: ProcessInfo.processInfo.environment["AETHER_FUZZ_REPLAY"] != nil)) func replay() throws { let d = [UInt8](try Data(contentsOf: URL(fileURLWithPath: ProcessInfo.processInfo.environment["AETHER_FUZZ_REPLAY"]!))) diff --git a/Tests/AetherEngineTests/DoviFelFuzzTests.swift b/Tests/AetherEngineTests/DoviFelFuzzTests.swift index 537717868..61b561b47 100644 --- a/Tests/AetherEngineTests/DoviFelFuzzTests.swift +++ b/Tests/AetherEngineTests/DoviFelFuzzTests.swift @@ -6,252 +6,254 @@ import AetherLibavutil import Dovi @testable import AetherEngine -/// Corrupt and hostile input must degrade (nil parameters, dropped EL, base layer -/// only), never crash or hang. Seeds are real packets/RPUs from the FEL fixtures; -/// mutations are deterministic so a failure reproduces. -@Suite(.offCooperativePoolOneLane) -struct DoviFelFuzzTests { +extension HeavyDualLayerSuites { + /// Corrupt and hostile input must degrade (nil parameters, dropped EL, base layer + /// only), never crash or hang. Seeds are real packets/RPUs from the FEL fixtures; + /// mutations are deterministic so a failure reproduces. + @Suite(.offCooperativePool) + struct DoviFelFuzzTests { - /// SplitMix64: deterministic, no dependence on the system RNG. - struct RNG { - var state: UInt64 - mutating func next() -> UInt64 { - state &+= 0x9E3779B97F4A7C15 - var z = state - z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 - z = (z ^ (z >> 27)) &* 0x94D049BB133111EB - return z ^ (z >> 31) + /// SplitMix64: deterministic, no dependence on the system RNG. + struct RNG { + var state: UInt64 + mutating func next() -> UInt64 { + state &+= 0x9E3779B97F4A7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } + mutating func below(_ n: Int) -> Int { n <= 0 ? 0 : Int(next() % UInt64(n)) } } - mutating func below(_ n: Int) -> Int { n <= 0 ? 0 : Int(next() % UInt64(n)) } - } - /// First video packet that contains an RPU, as raw bytes, plus one RPU NAL. - static func seed(index: Int = 2) throws -> (packet: [UInt8], rpu: [UInt8])? { - let name = DoviFelRealFileTests.fixtures[index] - guard DoviFelRealFileTests.available(name) else { return nil } - let demuxer = Demuxer() - try demuxer.open(url: DoviFelRealFileTests.url(name)) - defer { demuxer.close() } - let idx = Int32(demuxer.videoStreamIndex) - while let pkt = try demuxer.readPacket() { - defer { - av_packet_unref(pkt) - var o: UnsafeMutablePointer? = pkt - trackedPacketFree(&o) + /// First video packet that contains an RPU, as raw bytes, plus one RPU NAL. + static func seed(index: Int = 2) throws -> (packet: [UInt8], rpu: [UInt8])? { + let name = DoviFelRealFileTests.fixtures[index] + guard DoviFelRealFileTests.available(name) else { return nil } + let demuxer = Demuxer() + try demuxer.open(url: DoviFelRealFileTests.url(name)) + defer { demuxer.close() } + let idx = Int32(demuxer.videoStreamIndex) + while let pkt = try demuxer.readPacket() { + defer { + av_packet_unref(pkt) + var o: UnsafeMutablePointer? = pkt + trackedPacketFree(&o) + } + guard pkt.pointee.stream_index == idx, let data = pkt.pointee.data else { continue } + let bytes = [UInt8](UnsafeBufferPointer(start: data, count: Int(pkt.pointee.size))) + let copy = makePacket(bytes) + defer { var o: UnsafeMutablePointer? = copy; trackedPacketFree(&o) } + let r = DoviRpuConverter.separateDualLayers(copy, extractEL: false, useCustomBlockSource: true, extractParams: true) + if let raw = r.rpuData { return (bytes, raw) } } - guard pkt.pointee.stream_index == idx, let data = pkt.pointee.data else { continue } - let bytes = [UInt8](UnsafeBufferPointer(start: data, count: Int(pkt.pointee.size))) - let copy = makePacket(bytes) - defer { var o: UnsafeMutablePointer? = copy; trackedPacketFree(&o) } - let r = DoviRpuConverter.separateDualLayers(copy, extractEL: false, useCustomBlockSource: true, extractParams: true) - if let raw = r.rpuData { return (bytes, raw) } + return nil } - return nil - } - /// Owns its buffer, padded the way FFmpeg packets are. - static func makePacket(_ bytes: [UInt8]) -> UnsafeMutablePointer { - let pkt = trackedPacketAlloc()! - let pad = Int(AV_INPUT_BUFFER_PADDING_SIZE) - let ref = av_buffer_alloc(bytes.count + pad)! - memset(ref.pointee.data, 0, bytes.count + pad) - _ = bytes.withUnsafeBufferPointer { memcpy(ref.pointee.data, $0.baseAddress!, bytes.count) } - pkt.pointee.buf = ref - pkt.pointee.data = ref.pointee.data - pkt.pointee.size = Int32(bytes.count) - pkt.pointee.pts = 0 - pkt.pointee.dts = 0 - return pkt - } + /// Owns its buffer, padded the way FFmpeg packets are. + static func makePacket(_ bytes: [UInt8]) -> UnsafeMutablePointer { + let pkt = trackedPacketAlloc()! + let pad = Int(AV_INPUT_BUFFER_PADDING_SIZE) + let ref = av_buffer_alloc(bytes.count + pad)! + memset(ref.pointee.data, 0, bytes.count + pad) + _ = bytes.withUnsafeBufferPointer { memcpy(ref.pointee.data, $0.baseAddress!, bytes.count) } + pkt.pointee.buf = ref + pkt.pointee.data = ref.pointee.data + pkt.pointee.size = Int32(bytes.count) + pkt.pointee.pts = 0 + pkt.pointee.dts = 0 + return pkt + } - /// Mutations only probe the parser if they start from an RPU libdovi really accepts. libdovi - /// hands back a handle even when parsing fails, so the error, not the handle, is the verdict. - static func expectLibdoviAccepts(_ nal: [UInt8], sourceLocation: SourceLocation = #_sourceLocation) throws { - let rpu = try #require(nal.withUnsafeBufferPointer { DoviRpuGuard.parse($0.baseAddress!, $0.count) }, - sourceLocation: sourceLocation) - defer { dovi_rpu_free(rpu) } - #expect(dovi_rpu_get_error(rpu) == nil, "\(dovi_rpu_get_error(rpu).map { String(cString: $0) } ?? "")", - sourceLocation: sourceLocation) - #if DualLayerDolbyVision - #expect(DoviFelComposer.parseRpuParameters(rpu: rpu) != nil, sourceLocation: sourceLocation) - #endif - } + /// Mutations only probe the parser if they start from an RPU libdovi really accepts. libdovi + /// hands back a handle even when parsing fails, so the error, not the handle, is the verdict. + static func expectLibdoviAccepts(_ nal: [UInt8], sourceLocation: SourceLocation = #_sourceLocation) throws { + let rpu = try #require(nal.withUnsafeBufferPointer { DoviRpuGuard.parse($0.baseAddress!, $0.count) }, + sourceLocation: sourceLocation) + defer { dovi_rpu_free(rpu) } + #expect(dovi_rpu_get_error(rpu) == nil, "\(dovi_rpu_get_error(rpu).map { String(cString: $0) } ?? "")", + sourceLocation: sourceLocation) + #if DualLayerDolbyVision + #expect(DoviFelComposer.parseRpuParameters(rpu: rpu) != nil, sourceLocation: sourceLocation) + #endif + } - static func mutate(_ input: [UInt8], _ rng: inout RNG) -> [UInt8] { - var b = input - switch rng.below(6) { - case 0: // flip a few bits - for _ in 0..<(1 + rng.below(8)) where !b.isEmpty { b[rng.below(b.count)] ^= UInt8(1 << rng.below(8)) } - case 1: // truncate - b = Array(b.prefix(rng.below(b.count + 1))) - case 2: // overwrite a run with 0xFF / 0x00 - if !b.isEmpty { - let start = rng.below(b.count), len = 1 + rng.below(16) - let v: UInt8 = rng.below(2) == 0 ? 0xFF : 0x00 - for i in start.. 4 { - let start = rng.below(b.count - 1), len = 1 + rng.below(min(32, b.count - start)) - b.insert(contentsOf: b[start..<(start + len)], at: start) + static func mutate(_ input: [UInt8], _ rng: inout RNG) -> [UInt8] { + var b = input + switch rng.below(6) { + case 0: // flip a few bits + for _ in 0..<(1 + rng.below(8)) where !b.isEmpty { b[rng.below(b.count)] ^= UInt8(1 << rng.below(8)) } + case 1: // truncate + b = Array(b.prefix(rng.below(b.count + 1))) + case 2: // overwrite a run with 0xFF / 0x00 + if !b.isEmpty { + let start = rng.below(b.count), len = 1 + rng.below(16) + let v: UInt8 = rng.below(2) == 0 ? 0xFF : 0x00 + for i in start.. 4 { + let start = rng.below(b.count - 1), len = 1 + rng.below(min(32, b.count - start)) + b.insert(contentsOf: b[start..<(start + len)], at: start) + } + default: // extend with junk + for _ in 0.. [UInt8] { - func framed(_ nal: [UInt8]) -> [UInt8] { - let n = UInt32(nal.count) - return [UInt8(n >> 24), UInt8(n >> 16 & 0xFF), UInt8(n >> 8 & 0xFF), UInt8(n & 0xFF)] + nal + /// A length-prefixed access unit built from a synthetic RPU: a base-layer slice, an enhancement-layer + /// NAL (`unspec63`) and the RPU (`unspec62`), so the splitter fuzz runs without a clip. + static func syntheticPacket(rpu: [UInt8]) -> [UInt8] { + func framed(_ nal: [UInt8]) -> [UInt8] { + let n = UInt32(nal.count) + return [UInt8(n >> 24), UInt8(n >> 16 & 0xFF), UInt8(n >> 8 & 0xFF), UInt8(n & 0xFF)] + nal + } + let base: [UInt8] = [0x02, 0x01] + (0..<48).map { UInt8(truncatingIfNeeded: $0 &* 7 &+ 1) } + let el: [UInt8] = [0x7E, 0x01, 0x02, 0x01] + (0..<24).map { UInt8(truncatingIfNeeded: $0 &* 5 &+ 3) } + return framed(base) + framed(el) + framed(rpu) } - let base: [UInt8] = [0x02, 0x01] + (0..<48).map { UInt8(truncatingIfNeeded: $0 &* 7 &+ 1) } - let el: [UInt8] = [0x7E, 0x01, 0x02, 0x01] + (0..<24).map { UInt8(truncatingIfNeeded: $0 &* 5 &+ 3) } - return framed(base) + framed(el) + framed(rpu) - } - #if DualLayerDolbyVision - static func checkMutatedRpuParsing(seedRPU: [UInt8], label: String, rngState: UInt64, iterations: Int) throws { - #expect(seedRPU.withUnsafeBufferPointer { DoviRpuGuard.crcIsValid($0.baseAddress!, $0.count) }, "a seed RPU must pass the CRC guard") - try expectLibdoviAccepts(seedRPU) - var rng = RNG(state: rngState) - var parsed = 0, rejected = 0 - for _ in 0..= 2 { - let pieces = Int(poly.numPivots) - 1 - for i in 0..= 2 { + let pieces = Int(poly.numPivots) - 1 + for i in 0..= 1) + for n in [p.nlqY, p.nlqCb, p.nlqCr] { + #expect(n.nlqShift <= 23) + #expect(n.vdrInMax >= 0) + } + } else { + rejected += 1 } - #expect(p.blMax == 1023) - #expect(p.vdrScale >= 1) - for n in [p.nlqY, p.nlqCb, p.nlqCr] { - #expect(n.nlqShift <= 23) - #expect(n.vdrInMax >= 0) - } - } else { - rejected += 1 } + print("[fuzz] RPU \(label): \(parsed) parsed, \(rejected) rejected of \(iterations)") + #expect(parsed + rejected > iterations * 3 / 4) } - print("[fuzz] RPU \(label): \(parsed) parsed, \(rejected) rejected of \(iterations)") - #expect(parsed + rejected > iterations * 3 / 4) - } - @Test("a mutated synthetic RPU never crashes libdovi or the parameter parser; the result is nil or self-consistent", - .timeLimit(.minutes(2)), arguments: DoviCrcValidFuzzTests.syntheticSeeds.indices) - func mutatedSyntheticRpuParsing(index: Int) throws { - let seed = DoviCrcValidFuzzTests.syntheticSeeds[index] - try Self.checkMutatedRpuParsing(seedRPU: seed.nal, label: seed.name, rngState: 0xFE1 + UInt64(index), iterations: 4000) - } + @Test("a mutated synthetic RPU never crashes libdovi or the parameter parser; the result is nil or self-consistent", + .timeLimit(.minutes(2)), arguments: DoviCrcValidFuzzTests.syntheticSeeds.indices) + func mutatedSyntheticRpuParsing(index: Int) throws { + let seed = DoviCrcValidFuzzTests.syntheticSeeds[index] + try Self.checkMutatedRpuParsing(seedRPU: seed.nal, label: seed.name, rngState: 0xFE1 + UInt64(index), iterations: 4000) + } - @Test("a mutated RPU from a real clip never crashes libdovi or the parameter parser; the result is nil or self-consistent", - .enabled(if: DoviFelRealFileTests.available(DoviFelRealFileTests.fixtures[2]), DoviFelRealFileTests.needsClips), - .timeLimit(.minutes(2))) - func mutatedRpuParsing() throws { - let seed = try #require(try Self.seed()) - try Self.checkMutatedRpuParsing(seedRPU: seed.rpu, label: "clip", rngState: 0xFE1, iterations: 4000) - } - #endif + @Test("a mutated RPU from a real clip never crashes libdovi or the parameter parser; the result is nil or self-consistent", + .enabled(if: DoviFelRealFileTests.available(DoviFelRealFileTests.fixtures[2]), DoviFelRealFileTests.needsClips), + .timeLimit(.minutes(2))) + func mutatedRpuParsing() throws { + let seed = try #require(try Self.seed()) + try Self.checkMutatedRpuParsing(seedRPU: seed.rpu, label: "clip", rngState: 0xFE1, iterations: 4000) + } + #endif - static func checkMutatedPacketSplitting(seedPacket: [UInt8], useCustomBlockSource: Bool, iterations: Int) { - var rng = RNG(state: useCustomBlockSource ? 0xA11 : 0xB22) - for i in 0..= 0 && off + 4 + n <= size, "iteration \(i): EL NAL overruns its packet") - if off + 4 + n > size { break } - off += 4 + n + static func checkMutatedPacketSplitting(seedPacket: [UInt8], useCustomBlockSource: Bool, iterations: Int) { + var rng = RNG(state: useCustomBlockSource ? 0xA11 : 0xB22) + for i in 0..= 0 && off + 4 + n <= size, "iteration \(i): EL NAL overruns its packet") + if off + 4 + n > size { break } + off += 4 + n + } + #expect(off == size || size - off < 4, "iteration \(i): trailing bytes in EL packet") + var o: UnsafeMutablePointer? = el + trackedPacketFree(&o) } - #expect(off == size || size - off < 4, "iteration \(i): trailing bytes in EL packet") - var o: UnsafeMutablePointer? = el + var o: UnsafeMutablePointer? = pkt trackedPacketFree(&o) } - var o: UnsafeMutablePointer? = pkt - trackedPacketFree(&o) } - } - @Test("a mutated or truncated synthetic access unit is split without crashing, and never reads past its buffer", - .timeLimit(.minutes(3)), - arguments: DoviCrcValidFuzzTests.syntheticSeeds.indices, [true, false]) - func mutatedSyntheticPacketSplitting(index: Int, useCustomBlockSource: Bool) { - let packet = Self.syntheticPacket(rpu: DoviCrcValidFuzzTests.syntheticSeeds[index].nal) - Self.checkMutatedPacketSplitting(seedPacket: packet, useCustomBlockSource: useCustomBlockSource, iterations: 1500) - } + @Test("a mutated or truncated synthetic access unit is split without crashing, and never reads past its buffer", + .timeLimit(.minutes(3)), + arguments: DoviCrcValidFuzzTests.syntheticSeeds.indices, [true, false]) + func mutatedSyntheticPacketSplitting(index: Int, useCustomBlockSource: Bool) { + let packet = Self.syntheticPacket(rpu: DoviCrcValidFuzzTests.syntheticSeeds[index].nal) + Self.checkMutatedPacketSplitting(seedPacket: packet, useCustomBlockSource: useCustomBlockSource, iterations: 1500) + } - @Test("a mutated or truncated access unit from a real clip is split without crashing, and never reads past its buffer", - .enabled(if: DoviFelRealFileTests.available(DoviFelRealFileTests.fixtures[2]), DoviFelRealFileTests.needsClips), - .timeLimit(.minutes(3)), - arguments: [true, false]) - func mutatedPacketSplitting(useCustomBlockSource: Bool) throws { - let seed = try #require(try Self.seed()) - Self.checkMutatedPacketSplitting(seedPacket: seed.packet, useCustomBlockSource: useCustomBlockSource, iterations: 1500) - } + @Test("a mutated or truncated access unit from a real clip is split without crashing, and never reads past its buffer", + .enabled(if: DoviFelRealFileTests.available(DoviFelRealFileTests.fixtures[2]), DoviFelRealFileTests.needsClips), + .timeLimit(.minutes(3)), + arguments: [true, false]) + func mutatedPacketSplitting(useCustomBlockSource: Bool) throws { + let seed = try #require(try Self.seed()) + Self.checkMutatedPacketSplitting(seedPacket: seed.packet, useCustomBlockSource: useCustomBlockSource, iterations: 1500) + } - @Test("length prefixes that lie about their size are contained by the NAL walker", - arguments: [0xFFFF_FFFF, 0x7FFF_FFFF, 0x8000_0000, 0x0000_1000, 0x0000_0004, 0]) - func lyingLengthPrefix(length: UInt32) throws { - // [len][2-byte NAL header + 6 bytes] — the prefix claims `length`. - var bytes: [UInt8] = [ - UInt8(length >> 24 & 0xFF), UInt8(length >> 16 & 0xFF), UInt8(length >> 8 & 0xFF), UInt8(length & 0xFF), - 0x7E, 0x01, 1, 2, 3, 4, 5, 6, - ] - for variant in 0..<3 { - if variant == 1 { bytes += bytes } // a second, equally wrong, NAL - if variant == 2 { bytes = Array(bytes.prefix(6)) } // cut inside the header - let pkt = Self.makePacket(bytes) - let r = DoviRpuConverter.separateDualLayers(pkt, extractEL: true, useCustomBlockSource: false, extractParams: true) - if let el = r.elPacket { var o: UnsafeMutablePointer? = el; trackedPacketFree(&o) } - var o: UnsafeMutablePointer? = pkt - trackedPacketFree(&o) + @Test("length prefixes that lie about their size are contained by the NAL walker", + arguments: [0xFFFF_FFFF, 0x7FFF_FFFF, 0x8000_0000, 0x0000_1000, 0x0000_0004, 0]) + func lyingLengthPrefix(length: UInt32) throws { + // [len][2-byte NAL header + 6 bytes] — the prefix claims `length`. + var bytes: [UInt8] = [ + UInt8(length >> 24 & 0xFF), UInt8(length >> 16 & 0xFF), UInt8(length >> 8 & 0xFF), UInt8(length & 0xFF), + 0x7E, 0x01, 1, 2, 3, 4, 5, 6, + ] + for variant in 0..<3 { + if variant == 1 { bytes += bytes } // a second, equally wrong, NAL + if variant == 2 { bytes = Array(bytes.prefix(6)) } // cut inside the header + let pkt = Self.makePacket(bytes) + let r = DoviRpuConverter.separateDualLayers(pkt, extractEL: true, useCustomBlockSource: false, extractParams: true) + if let el = r.elPacket { var o: UnsafeMutablePointer? = el; trackedPacketFree(&o) } + var o: UnsafeMutablePointer? = pkt + trackedPacketFree(&o) + } } - } - @Test("empty, tiny and null-data packets are handled") - func degeneratePackets() { - for size in [0, 1, 2, 3, 4, 5] { - let pkt = Self.makePacket([UInt8](repeating: 0x7E, count: size)) - pkt.pointee.size = Int32(size) - let r = DoviRpuConverter.separateDualLayers(pkt, extractEL: true, useCustomBlockSource: false, extractParams: true) - #expect(r.elPacket == nil) - var o: UnsafeMutablePointer? = pkt + @Test("empty, tiny and null-data packets are handled") + func degeneratePackets() { + for size in [0, 1, 2, 3, 4, 5] { + let pkt = Self.makePacket([UInt8](repeating: 0x7E, count: size)) + pkt.pointee.size = Int32(size) + let r = DoviRpuConverter.separateDualLayers(pkt, extractEL: true, useCustomBlockSource: false, extractParams: true) + #expect(r.elPacket == nil) + var o: UnsafeMutablePointer? = pkt + trackedPacketFree(&o) + } + let empty = trackedPacketAlloc()! // data == nil + let r = DoviRpuConverter.separateDualLayers(empty, extractEL: true, useCustomBlockSource: true, extractParams: true) + #expect(r.elPacket == nil && r.rpuData == nil) + var o: UnsafeMutablePointer? = empty trackedPacketFree(&o) } - let empty = trackedPacketAlloc()! // data == nil - let r = DoviRpuConverter.separateDualLayers(empty, extractEL: true, useCustomBlockSource: true, extractParams: true) - #expect(r.elPacket == nil && r.rpuData == nil) - var o: UnsafeMutablePointer? = empty - trackedPacketFree(&o) } } diff --git a/Tests/AetherEngineTests/DoviFelInPlaceComposeTests.swift b/Tests/AetherEngineTests/DoviFelInPlaceComposeTests.swift new file mode 100644 index 000000000..a81ac613d --- /dev/null +++ b/Tests/AetherEngineTests/DoviFelInPlaceComposeTests.swift @@ -0,0 +1,440 @@ +#if DualLayerDolbyVision +import Testing +import CoreMedia +import CoreVideo +import Foundation +import Metal +@testable import AetherEngine + +/// Composing into the decoded base-layer buffer instead of a buffer from the output pool. +/// +/// The picture has to be the same bit for bit, the pool must never come into existence, the buffer's +/// attachments must end up as a pool buffer's would, a frame that failed half way must not be shown, and +/// anything the in-place order cannot handle must take the pool path. +@Suite(.offCooperativePoolOneLane, .enabled(if: MTLCreateSystemDefaultDevice() != nil, "needs a Metal device")) +struct DoviFelInPlaceComposeTests { + + typealias H = DoviGpuCurveSweepTests + + // MARK: - Fixtures + + /// Cheap deterministic noise, so every sample differs from its neighbours and a wrong neighbour read shows. + private static func noise(_ x: Int, _ y: Int, _ c: Int, _ seed: Int) -> Int { + var h = UInt32(truncatingIfNeeded: x &* 73856093 ^ y &* 19349663 ^ c &* 83492791 ^ seed &* 2654435761) + h ^= h >> 15; h = h &* 2246822519; h ^= h >> 13; h = h &* 3266489917; h ^= h >> 16 + return Int(h & 0xFFFF) + } + + /// A base layer in video range with texture, and an enhancement layer of residual codes around mid-grey. + static func makeBaseAndEnhancement(width: Int, height: Int, seed: Int) -> (bl: CVPixelBuffer, el: CVPixelBuffer)? { + guard let bl = H.makeBuffer(width: width, height: height), + let el = H.makeBuffer(width: width / 2, height: height / 2) else { return nil } + H.fillPlane(bl, plane: 0, components: 1) { x, y, _ in 64 + (x * 3 + y * 5 + noise(x, y, 0, seed) % 64) % 876 } + H.fillPlane(bl, plane: 1, components: 2) { x, y, c in 64 + (x * 7 + y * 3 + noise(x, y, 1 + c, seed) % 96) % 896 } + H.fillPlane(el, plane: 0, components: 1) { x, y, _ in 480 + noise(x, y, 5, seed) % 64 } + H.fillPlane(el, plane: 1, components: 2) { x, y, c in 490 + noise(x, y, 6 + c, seed) % 44 } + return (bl, el) + } + + private static func polynomial(_ c0: Double, _ c1: Double, _ c2: Double, order: UInt32) -> DoviPolynomialParams { + var poly = DoviPolynomialParams() + poly.numPivots = 2 + poly.pivots[0] = 0; poly.pivots[1] = 1023 + poly.method[0] = DoviMapping.polynomial.rawValue + poly.order[0] = order + poly.coefShift[0] = 23 + poly.coef[0] = [Int32(c0 * 8388608.0), Int32(c1 * 8388608.0), Int32(c2 * 8388608.0)] + return poly + } + + /// Curves that actually move every component, and a residual on all three, so a plane that is read after + /// it was overwritten changes the result. + static func params(dither: DoviDither = .bayer8x8, residual: Bool = true, chroma: ChromaCurves = .polynomial) -> DoviMetalParams { + var p = DoviGpuEdgeCaseTests.params(flags: residual ? [] : [.disableResidual]) + p.polyY = polynomial(0.02, 0.93, 0.05, order: 2) + p.polyCb = polynomial(0.03, 0.96, 0, order: 1) + p.polyCr = polynomial(-0.01, 1.03, 0, order: 1) + p.ditherMode = dither + switch chroma { + case .polynomial: + break + case .mmrOnePiece: + // Both chroma curves lean on luma (y, y*u, y*v, y^2), so the chroma pass reads the base luma. + (p.polyCb, p.mmrCb) = mmr(pivots: [0, 1023], orders: [1], + constants: [0.05], terms: [[0: 0.5, 1: 0.35, 3: 0.1]]) + (p.polyCr, p.mmrCr) = mmr(pivots: [0, 1023], orders: [2], + constants: [0.02], terms: [[0: 0.4, 2: 0.4, 4: 0.1, 7: 0.05, 11: 0.05]]) + case .mmrTwoPieces: + // Cb switches pieces at its own code 512 (order 1, then order 2); Cr keeps one order-3 piece. + (p.polyCb, p.mmrCb) = mmr(pivots: [0, 512, 1023], orders: [1, 2], + constants: [0.05, 0.1], + terms: [[0: 0.55, 1: 0.3, 3: 0.1], [0: 0.3, 1: 0.4, 7: 0.2, 10: 0.1]]) + (p.polyCr, p.mmrCr) = mmr(pivots: [0, 1023], orders: [3], + constants: [0.03], + terms: [[0: 0.35, 2: 0.35, 4: 0.12, 7: 0.1, 14: 0.05, 18: 0.04]]) + } + return p + } + + /// Which curves the chroma components use. Only the MMR ones make the chroma pass read base luma. + enum ChromaCurves: Sendable, CaseIterable { case polynomial, mmrOnePiece, mmrTwoPieces } + + /// An MMR curve: `terms[piece]` maps `order * 7 + term` over `[y, u, v, yu, yv, uv, yuv]` to a coefficient. + private static func mmr(pivots: [UInt32], orders: [UInt32], constants: [Double], + terms: [[Int: Double]]) -> (DoviPolynomialParams, DoviMmrParams) { + var poly = DoviPolynomialParams() + poly.numPivots = UInt32(pivots.count) + var m = DoviMmrParams() + m.coefShift = 23 + for (i, pivot) in pivots.enumerated() { poly.pivots[i] = pivot } + for piece in 0.. [UInt16] { + CVPixelBufferLockBaseAddress(buffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) } + guard let base = CVPixelBufferGetBaseAddressOfPlane(buffer, plane) else { return [] } + let rows = CVPixelBufferGetHeightOfPlane(buffer, plane) + let samplesPerRow = CVPixelBufferGetWidthOfPlane(buffer, plane) * (plane == 0 ? 1 : 2) + let stride = CVPixelBufferGetBytesPerRowOfPlane(buffer, plane) + var out: [UInt16] = [] + out.reserveCapacity(rows * samplesPerRow) + for r in 0.. DoviFelComposer.InPlaceResult { + composer.acquireSlot() + return await withCheckedContinuation { cont in + composer.composeInPlaceAsyncWithAcquiredSlot(baseBuffer: bl, enhancementBuffer: el, params: params, + frameIndex: frameIndex) { cont.resume(returning: $0) } + } + } + + // MARK: - Bit-exactness + + @Test("in-place output equals the pool output bit for bit, all planes, every dither mode, residual setting and chroma curve kind", + arguments: [ + (256, 128, DoviDither.bayer8x8, true, ChromaCurves.polynomial), + (256, 128, DoviDither.none, true, ChromaCurves.polynomial), + (256, 128, DoviDither.fullPrecision, true, ChromaCurves.polynomial), + (256, 128, DoviDither.bayer8x8, false, ChromaCurves.polynomial), + (320, 180, DoviDither.bayer8x8, true, ChromaCurves.polynomial), // not a multiple of the threadgroup in either direction + (1920, 1080, DoviDither.bayer8x8, true, ChromaCurves.polynomial), + // Chroma MMR: the chroma pass reads the base luma (a [1 2 1] neighbourhood, edges clamped), which + // is the cross-plane read the in-place pass order protects. + (256, 128, DoviDither.bayer8x8, true, ChromaCurves.mmrOnePiece), + (256, 128, DoviDither.none, false, ChromaCurves.mmrOnePiece), + (320, 180, DoviDither.bayer8x8, true, ChromaCurves.mmrTwoPieces), + (256, 128, DoviDither.fullPrecision, true, ChromaCurves.mmrTwoPieces), + (1920, 1080, DoviDither.bayer8x8, true, ChromaCurves.mmrTwoPieces), + ]) + func inPlaceMatchesPool(width: Int, height: Int, dither: DoviDither, residual: Bool, chroma: ChromaCurves) async throws { + let composer = try #require(DoviFelComposer()) + let params = Self.params(dither: dither, residual: residual, chroma: chroma) + for frame in [UInt32(0), 7, 8] { + let a = try #require(Self.makeBaseAndEnhancement(width: width, height: height, seed: Int(frame) + 1)) + let b = try #require(Self.makeBaseAndEnhancement(width: width, height: height, seed: Int(frame) + 1)) + let pooled = try #require(composer.compose(baseBuffer: a.bl, enhancementBuffer: a.el, params: params, frameIndex: frame)) + let result = await Self.composeInPlace(composer, bl: b.bl, el: b.el, params: params, frameIndex: frame) + #expect(result == .composed) + for plane in 0..<2 { + let expected = Self.visibleWords(pooled, plane: plane) + let got = Self.visibleWords(b.bl, plane: plane) + #expect(!expected.isEmpty) + #expect(got == expected, "plane \(plane) differs at frame \(frame), \(width)x\(height), \(dither), residual \(residual), \(chroma)") + } + // The pool path never touches its base layer; make sure the control really was the base layer's + // opposite, i.e. in place changed the base buffer and the pool path did not. + #expect(Self.visibleWords(a.bl, plane: 0) != Self.visibleWords(pooled, plane: 0)) + } + } + + /// The guard has teeth only if chroma really depends on the luma the in-place order protects. Composing + /// the pool way once from the decoded base layer, and once from a base layer whose luma was already + /// replaced by the composed luma (what a swapped pass order or a missing barrier would feed the chroma + /// pass), must give different chroma for the MMR curves, and the same chroma for the polynomial ones + /// (which is why the polynomial-only argument sets alone could not catch an ordering fault). + @Test("chroma composed from already-composed luma differs for MMR chroma curves, so the bit-exact test can catch a wrong pass order", + arguments: ChromaCurves.allCases) + func orderSensitivityControl(chroma: ChromaCurves) async throws { + let (width, height) = (256, 128) + let composer = try #require(DoviFelComposer()) + let params = Self.params(chroma: chroma) + let original = try #require(Self.makeBaseAndEnhancement(width: width, height: height, seed: 21)) + let correct = try #require(composer.compose(baseBuffer: original.bl, enhancementBuffer: original.el, params: params, frameIndex: 0)) + + // The base layer as the chroma pass would see it after the luma pass had run. + let overwritten = try #require(Self.makeBaseAndEnhancement(width: width, height: height, seed: 21)) + let composedLuma = Self.visibleWords(correct, plane: 0) + #expect(composedLuma != Self.visibleWords(original.bl, plane: 0), "the luma curve must move luma") + H.fillPlane(overwritten.bl, plane: 0, components: 1) { x, y, _ in Int(composedLuma[y * width + x] >> 6) } + let wrong = try #require(composer.compose(baseBuffer: overwritten.bl, enhancementBuffer: overwritten.el, params: params, frameIndex: 0)) + + let same = Self.visibleWords(correct, plane: 1) == Self.visibleWords(wrong, plane: 1) + switch chroma { + case .polynomial: #expect(same, "polynomial chroma should not read luma") + case .mmrOnePiece, .mmrTwoPieces: #expect(!same, "MMR chroma must depend on the luma it is composed from") + } + } + + // MARK: - No pool + + @Test("in-place playback of eligible frames does not create the output pool; the blocking pool path does") + func poolIsNeverCreated() async throws { + let composer = try #require(DoviFelComposer()) + let params = Self.params() + for frame in 0..<8 { + let f = try #require(Self.makeBaseAndEnhancement(width: 256, height: 128, seed: frame)) + #expect(composer.canComposeInPlace(baseBuffer: f.bl, params: params)) + #expect(await Self.composeInPlace(composer, bl: f.bl, el: f.el, params: params, frameIndex: UInt32(frame)) == .composed) + } + #expect(!composer.hasOutputPool) + #expect(composer.allocatedPoolBuffers == 0) + #expect(composer.inPlaceComposedFrames == 8) + + // Control: the pool path does build it. + let f = try #require(Self.makeBaseAndEnhancement(width: 256, height: 128, seed: 99)) + #expect(composer.compose(baseBuffer: f.bl, enhancementBuffer: f.el, params: params, frameIndex: 1) != nil) + #expect(composer.hasOutputPool) + #expect(composer.allocatedPoolBuffers >= 1) + } + + // MARK: - Fallback decision + + private static func makeBuffer(format: OSType, width: Int, height: Int, ioSurface: Bool) -> CVPixelBuffer? { + var out: CVPixelBuffer? + // Metal compatibility alone makes CoreVideo back the buffer with an IOSurface, so the plain + // buffer gets neither key. + var attrs: [CFString: Any] = [:] + if ioSurface { + attrs[kCVPixelBufferMetalCompatibilityKey] = true + attrs[kCVPixelBufferIOSurfacePropertiesKey] = [:] as CFDictionary + } + let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, format, attrs as CFDictionary, &out) + return status == kCVReturnSuccess ? out : nil + } + + @Test("the in-place decision: eligible only for an IOSurface-backed x420 buffer of even size without a luma MMR curve") + func decision() throws { + let composer = try #require(DoviFelComposer()) + let params = Self.params() + let good = try #require(Self.makeBuffer(format: kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, width: 256, height: 128, ioSurface: true)) + #expect(composer.inPlaceDecision(baseBuffer: good, params: params) == nil) + #expect(composer.canComposeInPlace(baseBuffer: good, params: params)) + + let noSurface = try #require(Self.makeBuffer(format: kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, width: 256, height: 128, ioSurface: false)) + #expect(composer.inPlaceDecision(baseBuffer: noSurface, params: params) == .notIOSurfaceBacked) + + let eightBit = try #require(Self.makeBuffer(format: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, width: 256, height: 128, ioSurface: true)) + #expect(composer.inPlaceDecision(baseBuffer: eightBit, params: params) == .notX420) + + let odd = try #require(Self.makeBuffer(format: kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, width: 255, height: 128, ioSurface: true)) + #expect(composer.inPlaceDecision(baseBuffer: odd, params: params) == .planeLayout) + + var mmr = params + mmr.flags.insert(.lumaMmr) + #expect(composer.inPlaceDecision(baseBuffer: good, params: mmr) == .lumaMmr) + + composer.inPlaceComposeEnabled = false + #expect(composer.inPlaceDecision(baseBuffer: good, params: params) == .switchedOff) + composer.inPlaceComposeEnabled = true + + composer.releaseOutputPool() + #expect(composer.inPlaceDecision(baseBuffer: good, params: params) == .tornDown) + } + + @Test("a frame the in-place order cannot handle composes through the pool, and matches an in-place-capable composer's own pool path") + func lumaMmrFrameUsesPool() async throws { + // The decision sends it to the pool; the encoder also refuses it directly, so a caller that skipped + // the question cannot get a picture built from chroma that was already overwritten. + let composer = try #require(DoviFelComposer()) + var mmr = Self.params() + mmr.flags.insert(.lumaMmr) + let f = try #require(Self.makeBaseAndEnhancement(width: 256, height: 128, seed: 3)) + let before = Self.visibleWords(f.bl, plane: 0) + #expect(await Self.composeInPlace(composer, bl: f.bl, el: f.el, params: mmr, frameIndex: 0) == .baseIntact) + #expect(Self.visibleWords(f.bl, plane: 0) == before, "a refused in-place composition changed the base layer") + #expect(!composer.hasOutputPool) + } + + // MARK: - Failure and exclusivity + + @Test("a command buffer that fails in place is reported as corrupt, never as a base layer to fall back to") + func failedInPlaceIsCorrupt() async throws { + let composer = try #require(DoviFelComposer()) + let params = Self.params() + let f = try #require(Self.makeBaseAndEnhancement(width: 256, height: 128, seed: 4)) + composer.forcedCommandBufferFailure.withLock { $0 = true } + #expect(await Self.composeInPlace(composer, bl: f.bl, el: f.el, params: params, frameIndex: 1) == .baseCorrupted) + #expect(composer.inPlaceLostFrames == 1) + #expect(composer.inPlaceComposedFrames == 0) + + // The claim and the slot were given back: another buffer composes once the fault is gone. + composer.forcedCommandBufferFailure.withLock { $0 = false } + let g = try #require(Self.makeBaseAndEnhancement(width: 256, height: 128, seed: 5)) + #expect(await Self.composeInPlace(composer, bl: g.bl, el: g.el, params: params, frameIndex: 2) == .composed) + #expect(!composer.hasOutputPool) + } + + @Test("a set-up failure leaves the base layer untouched and reports it as intact") + func setUpFailureLeavesBaseIntact() async throws { + let composer = try #require(DoviFelComposer()) + let params = Self.params() + let f = try #require(Self.makeBaseAndEnhancement(width: 256, height: 128, seed: 6)) + let before = (Self.visibleWords(f.bl, plane: 0), Self.visibleWords(f.bl, plane: 1)) + composer.releaseOutputPool() // torn down: every composition is refused + #expect(await Self.composeInPlace(composer, bl: f.bl, el: f.el, params: params, frameIndex: 1) == .baseIntact) + #expect(Self.visibleWords(f.bl, plane: 0) == before.0) + #expect(Self.visibleWords(f.bl, plane: 1) == before.1) + #expect(composer.inPlaceLostFrames == 0) + } + + @Test("a buffer that is already being composed is refused, not composed twice, and its slot is returned") + func sameBufferIsNotComposedTwice() async throws { + let composer = try #require(DoviFelComposer()) + let params = Self.params() + let f = try #require(Self.makeBaseAndEnhancement(width: 256, height: 128, seed: 7)) + #expect(composer.claimInPlaceForTesting(f.bl)) + let before = Self.visibleWords(f.bl, plane: 0) + // More attempts than slots: a leaked slot would hang here. + for i in 0..<(DoviFelComposer.maxFramesInFlight + 2) { + #expect(await Self.composeInPlace(composer, bl: f.bl, el: f.el, params: params, frameIndex: UInt32(i)) == .baseCorrupted) + } + #expect(Self.visibleWords(f.bl, plane: 0) == before) + composer.releaseInPlaceClaimForTesting(f.bl) + #expect(await Self.composeInPlace(composer, bl: f.bl, el: f.el, params: params, frameIndex: 9) == .composed) + } + + // MARK: - Attachments + + private static func attachments(_ buffer: CVPixelBuffer, _ mode: CVAttachmentMode) -> [String: String] { + let dict = CVBufferCopyAttachments(buffer, mode) as? [String: Any] ?? [:] + return dict.mapValues { "\($0)" } + } + + @Test("colour tags, HDR metadata and the Dolby Vision attachment end up as the pool path leaves them") + func attachmentsMatchPoolPath() async throws { + let composer = try #require(DoviFelComposer()) + let params = Self.params() + let a = try #require(Self.makeBaseAndEnhancement(width: 256, height: 128, seed: 8)) + let b = try #require(Self.makeBaseAndEnhancement(width: 256, height: 128, seed: 8)) + for buffer in [a.bl, b.bl] { + CVBufferSetAttachment(buffer, kCVImageBufferColorPrimariesKey, kCVImageBufferColorPrimaries_ITU_R_2020, .shouldPropagate) + CVBufferSetAttachment(buffer, kCVImageBufferTransferFunctionKey, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, .shouldPropagate) + CVBufferSetAttachment(buffer, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_2020, .shouldPropagate) + CVBufferSetAttachment(buffer, kCVImageBufferChromaLocationTopFieldKey, kCVImageBufferChromaLocation_TopLeft, .shouldPropagate) + CVBufferSetAttachment(buffer, kCVImageBufferMasteringDisplayColorVolumeKey, Data([1, 2, 3, 4]) as CFData, .shouldPropagate) + CVBufferSetAttachment(buffer, kCVImageBufferContentLightLevelInfoKey, Data([5, 6]) as CFData, .shouldPropagate) + // A stale RPU left by a previous tenant of the buffer, and a note that does not propagate. + CVBufferSetAttachment(buffer, DoviDisplayMetadata.rpuDataKey as CFString, Data([9, 9]) as CFData, .shouldPropagate) + CVBufferSetAttachment(buffer, "AetherTestPrivateNote" as CFString, "x" as CFString, .shouldNotPropagate) + } + let nal: [UInt8] = [0x7C, 0x01, 0x19, 0x42, 0x03] + + let pooled = try #require(composer.compose(baseBuffer: a.bl, enhancementBuffer: a.el, params: params, frameIndex: 3)) + #expect(await Self.composeInPlace(composer, bl: b.bl, el: b.el, params: params, frameIndex: 3) == .composed) + // What `OrderedDelivery.emit` does to either one. + DoviDisplayMetadata.attach(rpuNAL: nal, to: pooled) + DoviDisplayMetadata.attach(rpuNAL: nal, to: b.bl) + + for mode in [CVAttachmentMode.shouldPropagate, .shouldNotPropagate] { + let expected = Self.attachments(pooled, mode) + let got = Self.attachments(b.bl, mode) + #expect(got == expected, "attachments (\(mode.rawValue)) differ: \(got) vs \(expected)") + } + #expect(!Self.attachments(b.bl, .shouldPropagate).isEmpty) + + // The same on the underlying IOSurface (what a consumer reading the surface directly sees). + let keys: [CFString] = [kCVImageBufferColorPrimariesKey, kCVImageBufferTransferFunctionKey, + kCVImageBufferYCbCrMatrixKey, kCVImageBufferChromaLocationTopFieldKey, + kCVImageBufferMasteringDisplayColorVolumeKey, kCVImageBufferContentLightLevelInfoKey] + let surfaceA = try #require(CVPixelBufferGetIOSurface(pooled)).takeUnretainedValue() + let surfaceB = try #require(CVPixelBufferGetIOSurface(b.bl)).takeUnretainedValue() + for key in keys { + let expected = IOSurfaceCopyValue(surfaceA, key).map { "\($0)" } + let got = IOSurfaceCopyValue(surfaceB, key).map { "\($0)" } + #expect(got == expected, "IOSurface value for \(key) differs: \(String(describing: got)) vs \(String(describing: expected))") + } + #expect(Self.attachments(b.bl, .shouldNotPropagate).isEmpty) + + // No RPU for the frame: the stale one is removed from either, the colour tags stay. + DoviDisplayMetadata.attach(rpuNAL: nil, to: pooled) + DoviDisplayMetadata.attach(rpuNAL: nil, to: b.bl) + #expect(Self.attachments(b.bl, .shouldPropagate) == Self.attachments(pooled, .shouldPropagate)) + } + + // MARK: - Decoder + + /// A composer that claims in-place support and answers with a scripted result per frame. + final class ScriptedInPlaceComposer: FelComposing, @unchecked Sendable { + private let lock = NSLock() + private var results: [DoviFelComposer.InPlaceResult] + private(set) var poolCalls = 0 + init(_ results: [DoviFelComposer.InPlaceResult]) { self.results = results } + func acquireSlot() {} + func releaseSlot() {} + func flush() {} + func canComposeInPlace(baseBuffer: CVPixelBuffer, params: DoviMetalParams) -> Bool { true } + func composeInPlaceAsyncWithAcquiredSlot(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, params: DoviMetalParams, + frameIndex: UInt32, completion: @escaping @Sendable (DoviFelComposer.InPlaceResult) -> Void) { + lock.lock(); let r = results.isEmpty ? .composed : results.removeFirst(); lock.unlock() + completion(r) + } + func composeAsync(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, params: DoviMetalParams, + frameIndex: UInt32, completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { + lock.lock(); poolCalls += 1; lock.unlock(); completion(nil) + } + func composeAsyncWithAcquiredSlot(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, params: DoviMetalParams, + frameIndex: UInt32, completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { + lock.lock(); poolCalls += 1; lock.unlock(); completion(nil) + } + } + + private final class Delivered: @unchecked Sendable { + private let lock = NSLock() + private var ptsList: [Int64] = [] + func add(_ pts: CMTime) { lock.lock(); ptsList.append(pts.value); lock.unlock() } + var values: [Int64] { lock.lock(); defer { lock.unlock() }; return ptsList } + } + + @Test("the decoder drops a frame whose in-place composition failed after writing began, keeps the order of the rest, and counts it") + func decoderDropsCorruptFrame() async throws { + let ticks: Int64 = 3754 + func pts(_ i: Int64) -> CMTime { CMTime(value: i * ticks, timescale: 90000) } + let composer = ScriptedInPlaceComposer([.composed, .baseCorrupted, .baseIntact, .composed]) + let decoder = DualLayerVideoDecoder(composer: composer) + defer { decoder.close() } + let delivered = Delivered() + decoder.onFrame = { _, p, _ in delivered.add(p) } + decoder.primeForTesting(enhancementLayer: true) + for i in 0..<4 { decoder.seedRPUForTesting(pts: pts(Int64(i)), params: DoviMetalParams(), displayMetadataNAL: nil) } + for i in 0..<4 { + let f = try #require(Self.makeBaseAndEnhancement(width: 64, height: 64, seed: i)) + decoder.handleEnhancementLayerFrame(elBuffer: f.el, pts: pts(Int64(i))) + decoder.handleBaseLayerFrame(baseBuffer: f.bl, pts: pts(Int64(i)), hdr10Data: nil) + } + decoder.quiesceForTesting() + // Frame 1 (corrupt) is gone; frame 2 (set-up failure, base layer intact) is delivered as its base layer. + #expect(delivered.values == [0, 2 * ticks, 3 * ticks]) + #expect(decoder.composedFrameCount == 2) + #expect(composer.poolCalls == 0, "an in-place frame went through the pool path") + } +} +#endif diff --git a/Tests/AetherEngineTests/DoviFelMathTests.swift b/Tests/AetherEngineTests/DoviFelMathTests.swift index f4ea129c4..2306be1a4 100644 --- a/Tests/AetherEngineTests/DoviFelMathTests.swift +++ b/Tests/AetherEngineTests/DoviFelMathTests.swift @@ -324,5 +324,30 @@ struct DoviFelMathTests { let sampleY = Int(outY[0] >> 6) #expect(sampleY > 400 && sampleY < 600, "Output Y sample must be near mid-gray (~512), was \(sampleY)") } + + // MARK: - NLQ ceiling + + @Test("a vdr_in_max that shifts down to 0 drops the residual for that component, as an out-of-range one does") + func zeroVdrInMaxDropsResidual() { + func parse(maxInt: UInt64, maxFrac: UInt64) -> DoviNlqParams { + var nlq = DoviRpuDataNlq() + nlq.nlq_offset = (512, 512, 512) + nlq.linear_deadzone_slope = (1 << 20, 1 << 20, 1 << 20) // 0.125 at denominator 23 + nlq.vdr_in_max_int = (maxInt, maxInt, maxInt) + nlq.vdr_in_max = (maxFrac, maxFrac, maxFrac) + return withUnsafePointer(to: &nlq) { + DoviFelComposer.parseNlqParams(nlq: $0, component: 0, label: "Y", method: .linear, + coefLog2Denom: 23, nlqShift: 11, pivots: []) + } + } + let live = parse(maxInt: 0, maxFrac: 1 << 20) + #expect(live.nlqScale != 0) + #expect(live.vdrInMax > 0) + // 2^8 in Q23 shifts down by 11 to 0: no usable ceiling. + let degenerate = parse(maxInt: 0, maxFrac: 1 << 8) + #expect(degenerate.nlqScale == 0) + let zero = parse(maxInt: 0, maxFrac: 0) + #expect(zero.nlqScale == 0) + } } #endif diff --git a/Tests/AetherEngineTests/DoviFelRealFileTests.swift b/Tests/AetherEngineTests/DoviFelRealFileTests.swift index dec4587cc..bb613f309 100644 --- a/Tests/AetherEngineTests/DoviFelRealFileTests.swift +++ b/Tests/AetherEngineTests/DoviFelRealFileTests.swift @@ -7,53 +7,55 @@ import AetherLibavcodec import AetherLibavutil @testable import AetherEngine -/// End-to-end FEL checks on real Profile 7 files, where the synthetic suites -/// cannot reach: container framing quirks (the Matroska `packet->data != buf->data` -/// case that produced zero frames), real RPUs, real reorder, seeks and teardown. -/// -/// The fixtures (`fel-clip-a.mkv`, `fel-clip-b.mkv`, `fel-clip-c.mp4`) live in the -/// directory named by `DV_FEL_TESTS_DIR`, or in `DV FEL Tests/` next to the package; -/// they are not checked in, and every test skips when its file is absent. -@Suite(.offCooperativePoolOneLane) -struct DoviFelRealFileTests { - - static let fixtures = [ - "fel-clip-a.mkv", - "fel-clip-b.mkv", - "fel-clip-c.mp4", - ] - - static func url(_ name: String) -> URL { - if let dir = ProcessInfo.processInfo.environment["DV_FEL_TESTS_DIR"] { - return URL(fileURLWithPath: dir).appendingPathComponent(name) +extension HeavyDualLayerSuites { + /// End-to-end FEL checks on real Profile 7 files, where the synthetic suites + /// cannot reach: container framing quirks (the Matroska `packet->data != buf->data` + /// case that produced zero frames), real RPUs, real reorder, seeks and teardown. + /// + /// The fixtures (`fel-clip-a.mkv`, `fel-clip-b.mkv`, `fel-clip-c.mp4`) live in the + /// directory named by `DV_FEL_TESTS_DIR`, or in `DV FEL Tests/` next to the package; + /// they are not checked in, and every test skips when its file is absent. + @Suite(.offCooperativePool) + struct DoviFelRealFileTests { + + static let fixtures = [ + "fel-clip-a.mkv", + "fel-clip-b.mkv", + "fel-clip-c.mp4", + ] + + static func url(_ name: String) -> URL { + if let dir = ProcessInfo.processInfo.environment["DV_FEL_TESTS_DIR"] { + return URL(fileURLWithPath: dir).appendingPathComponent(name) + } + return URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // AetherEngineTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // AetherEngine + .deletingLastPathComponent() // repo root + .appendingPathComponent("DV FEL Tests") + .appendingPathComponent(name) } - return URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // AetherEngineTests - .deletingLastPathComponent() // Tests - .deletingLastPathComponent() // AetherEngine - .deletingLastPathComponent() // repo root - .appendingPathComponent("DV FEL Tests") - .appendingPathComponent(name) - } - static func available(_ name: String) -> Bool { - FileManager.default.fileExists(atPath: url(name).path) - } + static func available(_ name: String) -> Bool { + FileManager.default.fileExists(atPath: url(name).path) + } - /// The `.enabled(if:)` reason of every test that needs a clip: the clips are not checked in, so - /// those tests show as skipped without them rather than passing without having run. - static let needsClips: Comment = "needs the DV FEL test clips (not checked in): set DV_FEL_TESTS_DIR" + /// The `.enabled(if:)` reason of every test that needs a clip: the clips are not checked in, so + /// those tests show as skipped without them rather than passing without having run. + static let needsClips: Comment = "needs the DV FEL test clips (not checked in): set DV_FEL_TESTS_DIR" - /// The fixtures present, for `arguments:` of a test that runs once per clip. - static var availableFixtures: [String] { fixtures.filter(available) } + /// The fixtures present, for `arguments:` of a test that runs once per clip. + static var availableFixtures: [String] { fixtures.filter(available) } - /// The first two clips, when present: the ones long enough to seek into. - static var firstTwoClips: [String] { [fixtures[0], fixtures[1]].filter(available) } + /// The first two clips, when present: the ones long enough to seek into. + static var firstTwoClips: [String] { [fixtures[0], fixtures[1]].filter(available) } + } } // Only with the DualLayerDolbyVision trait: the composer is not built without it. #if DualLayerDolbyVision -extension DoviFelRealFileTests { +extension HeavyDualLayerSuites.DoviFelRealFileTests { // MARK: - Harness @@ -213,6 +215,75 @@ extension DoviFelRealFileTests { #expect(Set(pts).count == pts.count, "duplicate presentation timestamps delivered") } + @Test("every delivered frame carries its own packet's Profile 8.1 RPU: the composed form, or the mapping-preserving form when it was shown as its base layer", + .enabled(if: !DoviFelRealFileTests.availableFixtures.isEmpty, DoviFelRealFileTests.needsClips), + arguments: DoviFelRealFileTests.availableFixtures) + func deliveredFramesCarryTheirOwnRpu(name: String) throws { + // Expected: the 8.1 RPU of each packet, or the last good one before it in decode order. A composed + // frame carries the composed form (mapping removed); a frame the decoder bypassed (deadline, missing + // enhancement layer, failed composition) carries the base-layer form that keeps its mapping. Which + // frames were bypassed depends on machine load, so both forms are expected per packet. + let reference = Demuxer() + try reference.open(url: Self.url(name)) + let index = Int32(reference.videoStreamIndex) + let timeBase = try #require(reference.stream(at: reference.videoStreamIndex)).pointee.time_base + var expected: [Int64: Data] = [:] + var expectedBase: [Int64: Data] = [:] + var lastGood: [UInt8]? + var lastGoodBase: [UInt8]? + var packets = 0 + while packets < 240, let pkt = try? reference.readPacket() { + if pkt.pointee.stream_index == index { + packets += 1 + let layers = DoviRpuConverter.separateDualLayers(pkt, extractEL: false, extractParams: true) + if let converted = layers.convertedRpu { + lastGood = converted + // A missing mapping form falls back to the composed one, as the decoder does. + lastGoodBase = layers.baseLayerRpu ?? converted + } + let ms = Int64((Double(pkt.pointee.pts) * av_q2d(timeBase) * 1000).rounded()) + expected[ms] = DoviDisplayMetadata.payload(fromNAL: lastGood) + expectedBase[ms] = DoviDisplayMetadata.payload(fromNAL: lastGoodBase) + } + av_packet_unref(pkt) + var opt: UnsafeMutablePointer? = pkt + trackedPacketFree(&opt) + } + reference.close() + + let demuxer = Demuxer() + try demuxer.open(url: Self.url(name)) + let stream = try #require(demuxer.stream(at: demuxer.videoStreamIndex)) + final class Delivered: @unchecked Sendable { + let lock = NSLock() + var frames: [(Int64, Data?)] = [] + } + let delivered = Delivered() + let decoder = DualLayerVideoDecoder(composer: DoviFelComposer()) + try decoder.open(stream: stream) { buffer, pts, _ in + let payload = CVBufferCopyAttachment(buffer, DoviDisplayMetadata.rpuDataKey as CFString, nil) as? Data + delivered.lock.withLock { delivered.frames.append((Int64((pts.seconds * 1000).rounded()), payload)) } + } + let s = Session(demuxer: demuxer, decoder: decoder, sink: Sink(), videoIndex: Int32(demuxer.videoStreamIndex)) + defer { decoder.close(); demuxer.close() } + Self.feed(s, count: 240) + decoder.flush() + + let frames = delivered.lock.withLock { delivered.frames } + // Delivered and demuxed times round to the millisecond independently: allow one either way. + func expectedPayload(_ ms: Int64) -> Data? { expected[ms] ?? expected[ms - 1] ?? expected[ms + 1] } + func expectedBasePayload(_ ms: Int64) -> Data? { expectedBase[ms] ?? expectedBase[ms - 1] ?? expectedBase[ms + 1] } + let composedForm = frames.filter { ms, payload in payload != nil && payload == expectedPayload(ms) }.count + let matched = frames.filter { ms, payload in + payload != nil && (payload == expectedPayload(ms) || payload == expectedBasePayload(ms)) + }.count + print("[rpu] \(name): \(matched) of \(frames.count) delivered frames carry their packet's RPU (\(composedForm) in the composed form); composed \(decoder.composedFrameCount)") + #expect(frames.count >= 216) + #expect(matched == frames.count) + // Composition ran, and its frames carry the composed form (a bypassed frame carries the other one). + #expect(composedForm > 0 || decoder.composedFrameCount == 0, "no frame carries the composed RPU form") + } + @Test("the enhancement layer is aligned with the base layer: the residual acts, and never exceeds vdr_in_max", .enabled(if: !DoviFelRealFileTests.availableFixtures.isEmpty, DoviFelRealFileTests.needsClips), arguments: DoviFelRealFileTests.availableFixtures) diff --git a/Tests/AetherEngineTests/DoviGpuEdgeCaseTests.swift b/Tests/AetherEngineTests/DoviGpuEdgeCaseTests.swift index 8ed9e42cc..bcd84718f 100644 --- a/Tests/AetherEngineTests/DoviGpuEdgeCaseTests.swift +++ b/Tests/AetherEngineTests/DoviGpuEdgeCaseTests.swift @@ -195,7 +195,7 @@ struct DoviGpuEdgeCaseTests { // MARK: - Geometry @Test("GPU writes every pixel for sizes that are not a multiple of the threadgroup or of 8", - arguments: [(4, 4), (6, 4), (34, 10), (130, 18), (514, 6), (1030, 10)]) + arguments: [(4, 4), (6, 4), (34, 10), (130, 18), (514, 6), (1030, 10), (65, 17), (65, 16), (64, 17)]) func oddGeometryCoversEveryPixel(width: Int, height: Int) throws { let composer = try #require(DoviFelComposer()) // Residual of +100 over the whole frame: an unwritten pixel would read 0, @@ -206,19 +206,27 @@ struct DoviGpuEdgeCaseTests { ) #expect(Self.lumaValues(out) == [537], "luma \(width)x\(height)") let uv = Self.allSamples(out, plane: 1, components: 2) - #expect(uv.count == (width / 2) * (height / 2)) + #expect(uv.count == ((width + 1) / 2) * ((height + 1) / 2)) #expect(Set(uv.map { $0[0] }) == [537], "Cb \(width)x\(height)") #expect(Set(uv.map { $0[1] }) == [487], "Cr \(width)x\(height)") } - @Test("a frame whose enhancement chroma plane would be empty is refused, not composed from garbage", + @Test("a frame whose enhancement layer is one or three luma samples wide still has a chroma plane and composes it", arguments: [(2, 2), (6, 2)]) - func tooSmallForChromaIsRefused(width: Int, height: Int) throws { + func tinyEnhancementLayerStillComposes(width: Int, height: Int) throws { let composer = try #require(DoviFelComposer()) - let blBuf = try #require(H.makeBuffer(width: width, height: height)) - let elBuf = try #require(H.makeBuffer(width: width / 2, height: height / 2)) - #expect(composer.compose(baseBuffer: blBuf, enhancementBuffer: elBuf, params: Self.params()) == nil) - // and the failed attempt must not have leaked a parameter slot + // Chroma rounds up, so a narrow EL has a chroma plane of at least one sample rather than an empty one. + // The kernel divides by that size: a floored size would be 0 for a 1-sample-wide EL and sample at infinity. + let out = try Self.composeConstant( + composer, blWidth: width, blHeight: height, + bl: (512, 512, 512), el: (612, 612, 412), params: Self.params() + ) + #expect(Self.lumaValues(out) == [537], "luma \(width)x\(height)") + let uv = Self.allSamples(out, plane: 1, components: 2) + #expect(uv.count == ((width + 1) / 2) * ((height + 1) / 2)) + #expect(Set(uv.map { $0[0] }) == [537], "Cb \(width)x\(height)") + #expect(Set(uv.map { $0[1] }) == [487], "Cr \(width)x\(height)") + // and the composer still works afterwards (no leaked parameter slot) let ok = try Self.composeConstant(composer, bl: (512, 512, 512), el: (612, 512, 512), params: Self.params()) #expect(Self.lumaValues(ok) == [537]) } @@ -519,5 +527,49 @@ extension DoviGpuEdgeCaseTests { let c = try Self.composeConstant(composer, bl: (512, 512, 512), el: (512, 512, 412), params: p) #expect(Set(DoviTestSupport.rawVDR(c)[2]) == [3996]) } + + @Test("GPU luma MMR on odd sizes reads the last base-layer chroma column and row", + arguments: [(65, 17), (65, 16), (64, 17)]) + func oddSizesWithLumaMmr(width: Int, height: Int) throws { + let composer = try #require(DoviFelComposer()) + var poly = DoviPolynomialParams() + poly.numPivots = 2 + poly.pivots[0] = 0; poly.pivots[1] = 1023 + poly.method[0] = DoviMapping.mmr.rawValue + poly.order[0] = 1 + poly.coefShift[0] = 23 + var mmr = DoviMmrParams() + mmr.coefShift = 23 + mmr.coef[0][1] = 1 << 23 // luma out = BL Cb + var p = Self.params(flags: [.disableResidual, .lumaMmr]) + p.polyY = poly + p.mmrY = mmr + let out = try Self.composeConstant(composer, blWidth: width, blHeight: height, + bl: (800, 300, 700), el: (512, 512, 512), params: p) + #expect(Self.lumaValues(out) == [300], "\(width)x\(height)") + } + + /// The GPU's float-to-int conversion saturates, so this passes without the clamp too; it locks in the + /// result the clamp-before-convert in `dovi_denormalize` is meant to give. + @Test("GPU MMR result far outside the range ends at the VDR range limit", + arguments: [Int32.max, Int32.min]) + func mmrOutOfRangeClamps(coef: Int32) throws { + let composer = try #require(DoviFelComposer()) + var poly = DoviPolynomialParams() + poly.numPivots = 2 + poly.pivots[0] = 0; poly.pivots[1] = 1023 + poly.method[0] = DoviMapping.mmr.rawValue + poly.order[0] = 1 + poly.coefShift[0] = 0 + var mmr = DoviMmrParams() + mmr.coefShift = 0 + mmr.coef[0][1] = coef // u * 2^31, times 4092 in the denormalise: far past Int32 + var p = Self.params(flags: [.disableResidual, .lumaMmr]) + p.polyY = poly + p.mmrY = mmr + p.ditherMode = .debugRawVDR + let out = try Self.composeConstant(composer, bl: (512, 300, 700), el: (512, 512, 512), params: p) + #expect(Set(DoviTestSupport.rawVDR(out)[0]) == [coef > 0 ? 4095 : 0]) + } } #endif diff --git a/Tests/AetherEngineTests/DoviRealFileExportTests.swift b/Tests/AetherEngineTests/DoviRealFileExportTests.swift index 869b1e4b6..78ed10820 100644 --- a/Tests/AetherEngineTests/DoviRealFileExportTests.swift +++ b/Tests/AetherEngineTests/DoviRealFileExportTests.swift @@ -16,6 +16,7 @@ import AetherLibavutil /// not a ground truth. /// /// `AETHER_REAL_EXPORT= swift test --filter exportRealTitle` +@Suite(.offCooperativePoolOneLane) struct DoviRealFileExportTests { static func planar10(_ b: CVPixelBuffer) -> Data { diff --git a/Tests/AetherEngineTests/DoviShaderSyncTests.swift b/Tests/AetherEngineTests/DoviShaderSyncTests.swift index dedc9da77..78f2f17fb 100644 --- a/Tests/AetherEngineTests/DoviShaderSyncTests.swift +++ b/Tests/AetherEngineTests/DoviShaderSyncTests.swift @@ -13,7 +13,7 @@ struct DoviShaderSyncTests { @Test("The runtime-compiled source carries all expected kernels") func runtimeSourceCarriesKernels() { - for kernel in ["dovi_compose_luma", "dovi_compose_chroma", "dovi_layout_probe"] { + for kernel in ["dovi_compose_luma", "dovi_compose_chroma", "dovi_compose_chroma_422", "dovi_compose_chroma_444", "dovi_layout_probe"] { #expect(DoviShaderSource.source.contains("kernel void \(kernel)("), "runtime source is missing \(kernel)") } diff --git a/Tests/AetherEngineTests/DualLayerBypassRpuTests.swift b/Tests/AetherEngineTests/DualLayerBypassRpuTests.swift new file mode 100644 index 000000000..743ae7f43 --- /dev/null +++ b/Tests/AetherEngineTests/DualLayerBypassRpuTests.swift @@ -0,0 +1,167 @@ +#if DualLayerDolbyVision +import Testing +import CoreMedia +import CoreVideo +import Foundation +import AetherLibavformat +import AetherLibavcodec +@_spi(Diagnostics) @testable import AetherEngine + +/// Which RPU a delivered frame carries. A composed frame has been reshaped by the composer, so its RPU +/// has no mapping; a frame shown as its raw base layer (released without its enhancement layer, or left +/// intact by a failed composition) has not, so it carries the RPU that keeps the mapping and the display +/// side reshapes it. The two RPUs are stood in for by tagged byte strings: this pins the choice, and +/// `DoviComposedFrameRpuTests` pins what the two forms contain. +@Suite(.serialized, .timeLimit(.minutes(2))) +struct DualLayerBypassRpuTests { + + private static let composedTag: UInt8 = 0xC0 + private static let baseLayerTag: UInt8 = 0xB0 + private static let composedNAL: [UInt8] = [0x7C, 0x01, 0x19, composedTag] + private static let baseLayerNAL: [UInt8] = [0x7C, 0x01, 0x19, baseLayerTag] + + private static let frame = 1.0 / 24 + private static var margin: Double { max(Double(DualLayerVideoDecoder.deadlineMarginFrames) * frame, DualLayerVideoDecoder.deadlineMarginFloor) } + private static func pts(_ n: Int64) -> CMTime { CMTime(value: n * 3750, timescale: 90000) } + + private static func buffer(width: Int, height: Int) throws -> CVPixelBuffer { + var out: CVPixelBuffer? + let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, + kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, + [kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary] as CFDictionary, &out) + guard status == kCVReturnSuccess else { throw CocoaError(.fileWriteUnknown) } + return try #require(out) + } + + private static func params() -> DoviMetalParams { + var p = DoviMetalParams() + p.blWidth = 64; p.blHeight = 64; p.elWidth = 32; p.elHeight = 32 + return p + } + + /// Completes at once with `result`: the base buffer (a composition) or nil (a failure). + private final class Composer: FelComposing, @unchecked Sendable { + let fails: Bool + init(fails: Bool) { self.fails = fails } + func acquireSlot() {} + func releaseSlot() {} + func flush() {} + func composeAsync(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, params: DoviMetalParams, + frameIndex: UInt32, completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { + completion(fails ? nil : baseBuffer) + } + func composeAsyncWithAcquiredSlot(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { + completion(fails ? nil : baseBuffer) + } + } + + private final class FakeSession: SingleLayerVideoDecoder, @unchecked Sendable { + var onFrame: DecodedFrameHandler? + var onFirstHDR10PlusDetected: (@Sendable () -> Void)? + var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? + var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? + var skipUntilPTS: CMTime? + var feedEpoch: UInt64 = 0 + func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws {} + func open(parameterSets: [[UInt8]], timeBase: AVRational, onFrame: @escaping DecodedFrameHandler) throws {} + func decode(packet: UnsafeMutablePointer, epoch: UInt64?) {} + func flush() {} + func close() {} + } + + private final class Delivered: @unchecked Sendable { + private let lock = NSLock() + private var _tags: [UInt8?] = [] + func record(_ buffer: CVPixelBuffer) { + let data = CVBufferCopyAttachment(buffer, DoviDisplayMetadata.rpuDataKey as CFString, nil) as? Data + lock.lock(); _tags.append(data.flatMap { $0.count > 1 ? $0[1] : nil }); lock.unlock() + } + /// The tag byte of each delivered frame's RPU, nil for a frame with none. + var tags: [UInt8?] { lock.lock(); defer { lock.unlock() }; return _tags } + var count: Int { tags.count } + } + + private final class Clocks: @unchecked Sendable { + private let lock = NSLock() + private var _seconds = 0.0, _uptime = 1000.0 + var seconds: Double { get { lock.lock(); defer { lock.unlock() }; return _seconds } set { lock.lock(); _seconds = newValue; lock.unlock() } } + var uptime: Double { get { lock.lock(); defer { lock.unlock() }; return _uptime } set { lock.lock(); _uptime = newValue; lock.unlock() } } + } + + private struct Rig { let decoder: DualLayerVideoDecoder; let delivered: Delivered; let clocks: Clocks } + + private static func rig(composeFails: Bool = false) -> Rig { + let delivered = Delivered() + let clocks = Clocks() + let decoder = DualLayerVideoDecoder(composer: Composer(fails: composeFails), primaryDecoder: FakeSession(), enhancementDecoder: FakeSession()) + decoder.onFrame = { buffer, _, _ in delivered.record(buffer) } + decoder.primeForTesting(enhancementLayer: true) + decoder.uptimeProvider = { clocks.uptime } + decoder.setRenderClock({ DualLayerVideoDecoder.RenderClockSample(seconds: clocks.seconds, running: true, prerolling: false) }, frameRate: 24) + return Rig(decoder: decoder, delivered: delivered, clocks: clocks) + } + + private static func feedBase(_ r: Rig, _ n: Int64, baseLayerNAL: [UInt8]? = baseLayerNAL) throws { + r.decoder.seedRPUForTesting(pts: pts(n), params: params(), displayMetadataNAL: composedNAL, baseLayerNAL: baseLayerNAL) + r.decoder.handleBaseLayerFrame(baseBuffer: try buffer(width: 64, height: 64), pts: pts(n), hdr10Data: nil) + } + + @Test("a frame composed with its enhancement layer carries the RPU without a mapping") + func composedFrameCarriesTheComposedRpu() async throws { + let r = Self.rig() + defer { r.decoder.close() } + r.decoder.handleEnhancementLayerFrame(elBuffer: try Self.buffer(width: 32, height: 32), pts: Self.pts(240)) + try Self.feedBase(r, 240) + try await waitFor { r.delivered.count == 1 } + #expect(r.delivered.tags == [Self.composedTag]) + } + + @Test("an access unit with no enhancement layer goes out as its base layer with the mapping-preserving RPU") + func missingEnhancementLayerCarriesTheMappingRpu() async throws { + let r = Self.rig() + defer { r.decoder.close() } + r.decoder.noteEnhancementAbsentForTesting(pts: Self.pts(240)) + try Self.feedBase(r, 240) + try await waitFor { r.delivered.count == 1 } + #expect(r.delivered.tags == [Self.baseLayerTag]) + } + + @Test("a frame released by its display deadline carries the mapping-preserving RPU") + func deadlineBypassCarriesTheMappingRpu() async throws { + let r = Self.rig() + defer { r.decoder.close() } + let n: Int64 = 252 + r.clocks.seconds = Self.pts(n).seconds - Self.margin - 0.010 + try Self.feedBase(r, n) + r.clocks.uptime += 0.05 + r.decoder.runDeadlineSweep() + #expect(r.delivered.count == 0, "short of its release time") + r.clocks.seconds = Self.pts(n).seconds - Self.margin + r.decoder.runDeadlineSweep() + try await waitFor { r.delivered.count == 1 } + #expect(r.delivered.tags == [Self.baseLayerTag]) + } + + @Test("a frame whose composition failed goes out as its base layer with the mapping-preserving RPU") + func failedCompositionCarriesTheMappingRpu() async throws { + let r = Self.rig(composeFails: true) + defer { r.decoder.close() } + r.decoder.handleEnhancementLayerFrame(elBuffer: try Self.buffer(width: 32, height: 32), pts: Self.pts(240)) + try Self.feedBase(r, 240) + try await waitFor { r.delivered.count == 1 } + #expect(r.delivered.tags == [Self.baseLayerTag]) + } + + @Test("a frame with no mapping-preserving form falls back to the composed RPU, as before") + func missingMappingFormFallsBack() async throws { + let r = Self.rig() + defer { r.decoder.close() } + r.decoder.noteEnhancementAbsentForTesting(pts: Self.pts(240)) + try Self.feedBase(r, 240, baseLayerNAL: nil) + try await waitFor { r.delivered.count == 1 } + #expect(r.delivered.tags == [Self.composedTag]) + } +} +#endif diff --git a/Tests/AetherEngineTests/DualLayerDeadlineBypassTests.swift b/Tests/AetherEngineTests/DualLayerDeadlineBypassTests.swift new file mode 100644 index 000000000..3112d7871 --- /dev/null +++ b/Tests/AetherEngineTests/DualLayerDeadlineBypassTests.swift @@ -0,0 +1,777 @@ +#if DualLayerDolbyVision +import Testing +import CoreMedia +import CoreVideo +import Foundation +import AetherLibavformat +import AetherLibavcodec +@_spi(Diagnostics) @testable import AetherEngine + +/// A base-layer frame whose enhancement layer is missing or late is released by its presentation +/// deadline against the render clock, not by a count of newer base frames, and at once when the access +/// unit carried no enhancement layer. Everything is synthetic: tagged pixel buffers into the frame +/// handlers, a recording composer, an injected clock and uptime, no media and no GPU. +@Suite(.serialized, .timeLimit(.minutes(2))) +struct DualLayerDeadlineBypassTests { + + // MARK: - Fixtures + + private static let frame = 1.0 / 24 + private static var margin: Double { max(Double(DualLayerVideoDecoder.deadlineMarginFrames) * frame, DualLayerVideoDecoder.deadlineMarginFloor) } + /// Longer than the grace a waiting frame is given at 24 fps (one interval, 41.7 ms). + private static let pastGrace = 0.05 + + private static func buffer(width: Int, height: Int) -> CVPixelBuffer? { + var out: CVPixelBuffer? + let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, + kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, + [kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary] as CFDictionary, + &out) + return status == kCVReturnSuccess ? out : nil + } + private static func baseLayer() throws -> CVPixelBuffer { try #require(buffer(width: 64, height: 64)) } + private static func enhancementLayer() throws -> CVPixelBuffer { try #require(buffer(width: 32, height: 32)) } + + private static func params() -> DoviMetalParams { + var p = DoviMetalParams() + p.blWidth = 64; p.blHeight = 64; p.elWidth = 32; p.elHeight = 32 + return p + } + + /// 24 fps at 90 kHz, offset so the frames sit well ahead of a clock at 0. + private static func pts(_ frameNumber: Int64) -> CMTime { CMTime(value: frameNumber * 3750, timescale: 90000) } + + private struct Pair { let base: CVPixelBuffer; let enhancement: CVPixelBuffer } + + private final class RecordingComposer: FelComposing, @unchecked Sendable { + private let lock = NSLock() + private var _pairs: [Pair] = [] + var pairs: [Pair] { lock.lock(); defer { lock.unlock() }; return _pairs } + func acquireSlot() {} + func releaseSlot() {} + func flush() {} + func composeAsync(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, params: DoviMetalParams, + frameIndex: UInt32, completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { + composeAsyncWithAcquiredSlot(baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, + params: params, frameIndex: frameIndex, completion: completion) + } + func composeAsyncWithAcquiredSlot(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { + lock.lock(); _pairs.append(Pair(base: baseBuffer, enhancement: enhancementBuffer)); lock.unlock() + completion(baseBuffer) + } + } + + private final class FakeSession: SingleLayerVideoDecoder, @unchecked Sendable { + var onFrame: DecodedFrameHandler? + var onFirstHDR10PlusDetected: (@Sendable () -> Void)? + var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? + var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? + var skipUntilPTS: CMTime? + var feedEpoch: UInt64 = 0 + func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws {} + func open(parameterSets: [[UInt8]], timeBase: AVRational, onFrame: @escaping DecodedFrameHandler) throws {} + func decode(packet: UnsafeMutablePointer, epoch: UInt64?) {} + func flush() {} + func close() {} + } + + private final class Deliveries: @unchecked Sendable { + struct Frame { let buffer: CVPixelBuffer; let pts: CMTime } + private let lock = NSLock() + private var frames: [Frame] = [] + func record(_ buffer: CVPixelBuffer, _ pts: CMTime) { lock.lock(); frames.append(Frame(buffer: buffer, pts: pts)); lock.unlock() } + var all: [Frame] { lock.lock(); defer { lock.unlock() }; return frames } + var count: Int { all.count } + } + + /// A clock and an uptime the test moves by hand. + private final class Clocks: @unchecked Sendable { + private let lock = NSLock() + private var _seconds = 0.0, _running = true, _prerolling = false, _uptime = 1000.0 + var prerolling: Bool { get { lock.lock(); defer { lock.unlock() }; return _prerolling } set { lock.lock(); _prerolling = newValue; lock.unlock() } } + var seconds: Double { get { lock.lock(); defer { lock.unlock() }; return _seconds } set { lock.lock(); _seconds = newValue; lock.unlock() } } + var running: Bool { get { lock.lock(); defer { lock.unlock() }; return _running } set { lock.lock(); _running = newValue; lock.unlock() } } + var uptime: Double { get { lock.lock(); defer { lock.unlock() }; return _uptime } set { lock.lock(); _uptime = newValue; lock.unlock() } } + } + + private struct Rig { + let decoder: DualLayerVideoDecoder + let composer: RecordingComposer + let deliveries: Deliveries + let clocks: Clocks + } + + private static func rig(withClock: Bool = true, running: Bool = true, frameRate: Double = 24) -> Rig { + let composer = RecordingComposer() + let deliveries = Deliveries() + let clocks = Clocks() + clocks.running = running + let decoder = DualLayerVideoDecoder(composer: composer, primaryDecoder: FakeSession(), enhancementDecoder: FakeSession()) + decoder.onFrame = { buffer, pts, _ in deliveries.record(buffer, pts) } + decoder.primeForTesting(enhancementLayer: true) + decoder.uptimeProvider = { clocks.uptime } + if withClock { + decoder.setRenderClock({ RenderClockSample(seconds: clocks.seconds, running: clocks.running, prerolling: clocks.prerolling) }, frameRate: frameRate) + } + return Rig(decoder: decoder, composer: composer, deliveries: deliveries, clocks: clocks) + } + + private typealias RenderClockSample = DualLayerVideoDecoder.RenderClockSample + + private static func feedBase(_ r: Rig, _ n: Int64) throws -> CVPixelBuffer { + r.decoder.seedRPUForTesting(pts: pts(n), params: params(), displayMetadataNAL: nil) + let bl = try baseLayer() + r.decoder.handleBaseLayerFrame(baseBuffer: bl, pts: pts(n), hdr10Data: nil) + return bl + } + + // MARK: - 1. Enhancement layer known absent + + @Test("an access unit with no enhancement layer goes out as its base layer on arrival, with no hold at all") + func absentEnhancementLayerBypassesAtOnce() async throws { + let r = Self.rig() + defer { r.decoder.close() } + // Every frame is far ahead of the clock (10 s): a deadline rule would hold each of them. + r.clocks.seconds = 0 + var sent: [CVPixelBuffer] = [] + let count = 12 // more than the count rule and the deadline backstop together + for i in 0..= 1 } + handOver() + #expect(renderer.prerollSatisfied(clock: target, live: false) == false, "the hole at frame 1 holds the gate shut") + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1) + + // Wall time passes by the margin; nothing else arrives and the gate admits nothing. + r.clocks.uptime += Self.margin - 0.001 + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1, "inside the margin the late layer may still come") + r.clocks.uptime += 0.001 + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 0) + try await waitFor { r.deliveries.count == 6 } + handOver() + #expect(renderer.prerollSatisfied(clock: target, live: false) == true) + #expect(Self.margin < AudioOutput.prerollTimeout / 10, "an order of magnitude inside the 2 s timeout") + } + + // MARK: - 4. Absence note against queued enhancement frames and reordered streams + + @Test("an absence noted for a key whose enhancement frame is already queued still pairs, and the note is consumed") + func queuedEnhancementFrameWinsOverAbsenceNote() async throws { + let r = Self.rig() + defer { r.decoder.close() } + r.decoder.noteEnhancementAbsentForTesting(pts: Self.pts(240)) + let el = try Self.enhancementLayer() + r.decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(240)) + let bl = try Self.feedBase(r, 240) + try await waitFor { r.deliveries.count == 1 } + #expect(r.composer.pairs.first?.base === bl && r.composer.pairs.first?.enhancement === el) + #expect(r.decoder.absentEnhancementKeyCountForTesting == 0) + // The note is gone: a later frame at the same time would wait for a partner like any other. + _ = try Self.feedBase(r, 240) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1) + } + + typealias B = HEVCSyntheticBitstream + typealias T = HEVCSyntheticBitstream.T + + /// One access unit as `decode(packet:)` receives it: a base slice, and unless `el` is nil an + /// enhancement slice wrapped in NAL type 63 (with its parameter sets on the first unit, which have no + /// other way in). The base layer's parameter sets ride in band on the first unit too. + private static func unit(_ blType: Int, _ blPoc: Int, el: (type: Int, poc: Int)?, parameterSets: Bool = false) -> [UInt8] { + var nals: [[UInt8]] = [] + if parameterSets { nals.append(B.sps(pocLsbBits: 8)); nals.append(B.pps()) } + nals.append(B.slice(type: blType, pocLsb: blPoc & 0xFF, pocLsbBits: 8)) + if let el { + if parameterSets { nals.append(B.wrappedEL(B.sps(pocLsbBits: 8))); nals.append(B.wrappedEL(B.pps())) } + nals.append(B.wrappedEL(B.slice(type: el.type, pocLsb: el.poc & 0xFF, pocLsbBits: 8))) + } + return B.accessUnit(nals) + } + + /// Feeds one access unit through the production `decode(packet:)`, stamped with frame `n`'s time in + /// the decoder's 90 kHz time base. + private static func decodeUnit(_ decoder: DualLayerVideoDecoder, _ bytes: [UInt8], frame n: Int64) throws { + let packet = try #require(av_packet_alloc()) + defer { var p: UnsafeMutablePointer? = packet; av_packet_free(&p) } + #expect(av_new_packet(packet, Int32(bytes.count)) >= 0) + bytes.withUnsafeBufferPointer { packet.pointee.data.update(from: $0.baseAddress!, count: bytes.count) } + packet.pointee.pts = pts(n).value + packet.pointee.dts = pts(n).value + decoder.decode(packet: packet, epoch: nil) + } + + /// A rig whose decoder takes access units through `decode(packet:)`. + private static func packetRig() -> Rig { + let r = rig() + r.decoder.markPrimaryDecoderOpenForTesting() + return r + } + + /// The opening units of a stream whose enhancement pictures sit in other units than their base + /// frames: unit 2's enhancement picture (POC 3) is the base frame one unit on, so it is restamped. + private static func openingUnits() -> [[UInt8]] { + [unit(T.idrNLP, 0, el: (T.idrNLP, 0), parameterSets: true), + unit(T.trailR, 1, el: (T.trailR, 1)), + unit(T.trailR, 2, el: (T.trailR, 3))] + } + + @Test("on a stream whose enhancement pictures are restamped, a unit without one says nothing about its own base frame") + func reorderedStreamNeverBypassesOnAbsence() async throws { + let r = Self.packetRig() + defer { r.decoder.close() } + let first: Int64 = 240 + for (i, u) in Self.openingUnits().enumerated() { try Self.decodeUnit(r.decoder, u, frame: first + Int64(i)) } + #expect(r.decoder.absentEnhancementKeyCountForTesting == 0) + + // The stream is reordered by now: a unit with no enhancement layer notes nothing, so its base + // frame waits for a partner. + try Self.decodeUnit(r.decoder, Self.unit(T.trailR, 3, el: nil), frame: first + 3) + #expect(r.decoder.absentEnhancementKeyCountForTesting == 0, "no note on a reordered stream") + let bl = try Self.feedBase(r, first + 3) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1, "waits for its partner") + let el = try Self.enhancementLayer() + r.decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(first + 3)) + try await waitFor { r.deliveries.count == 1 } + #expect(r.composer.pairs.first?.base === bl && r.composer.pairs.first?.enhancement === el) + } + + @Test("before any restamp, a unit without an enhancement layer is noted, and the notes are dropped in the unit that first restamps") + func absenceNotesBeforeAndAtTheFirstRestamp() async throws { + let r = Self.packetRig() + defer { r.decoder.close() } + let first: Int64 = 240 + let units = Self.openingUnits() + try Self.decodeUnit(r.decoder, units[0], frame: first) + try Self.decodeUnit(r.decoder, units[1], frame: first + 1) + // A unit with no enhancement layer, while the pictures still line up: a real absence. + try Self.decodeUnit(r.decoder, Self.unit(T.trailR, 2, el: nil), frame: first + 2) + #expect(r.decoder.absentEnhancementKeyCountForTesting == 1) + let alone = try Self.feedBase(r, first + 2) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 0, "released at once") + try await waitFor { r.deliveries.count == 1 } + #expect(r.deliveries.all.first?.buffer === alone) + + // Another absence, then the unit whose enhancement picture is restamped: the note made before + // the reordering was seen goes with it, in the same unit. + try Self.decodeUnit(r.decoder, Self.unit(T.trailR, 3, el: nil), frame: first + 3) + #expect(r.decoder.absentEnhancementKeyCountForTesting == 1) + try Self.decodeUnit(r.decoder, Self.unit(T.trailR, 4, el: (T.trailR, 6)), frame: first + 4) + #expect(r.decoder.absentEnhancementKeyCountForTesting == 0, "dropped by the unit that restamps") + // And that unit's own absence is not noted either, judged after its restamp. + try Self.decodeUnit(r.decoder, Self.unit(T.trailR, 5, el: nil), frame: first + 5) + #expect(r.decoder.absentEnhancementKeyCountForTesting == 0) + } + + // MARK: - 5. Grace for a late enhancement layer, and the margin at high frame rates + + @Test("an enhancement layer that arrives just after its base frame, already inside the margin, pairs; the same frame is released once the grace is over") + func graceForALatePartner() async throws { + let r = Self.rig() + defer { r.decoder.close() } + let n: Int64 = 252 + // The clock is already past the release time: the deadline rule would fire on arrival. + r.clocks.seconds = Self.pts(n).seconds - Self.margin + 0.02 + let bl = try Self.feedBase(r, n) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1, "not released on arrival") + r.clocks.uptime += 0.01 // under max(one frame interval, 15 ms) + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1, "still inside the grace") + let el = try Self.enhancementLayer() + r.decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(n)) + try await waitFor { r.deliveries.count == 1 } + #expect(r.composer.pairs.first?.base === bl && r.composer.pairs.first?.enhancement === el) + + // A partner that does not come: out once the grace is over, not before. + let m = n + 1 + r.clocks.seconds = Self.pts(m).seconds - Self.margin + 0.02 + let alone = try Self.feedBase(r, m) + r.clocks.uptime += Self.frame - 0.002 + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1) + r.clocks.uptime += 0.004 + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 0) + try await waitFor { r.deliveries.count == 2 } + #expect(r.deliveries.all.last?.buffer === alone) + } + + @Test("at 60 fps the margin is the time floor, not three frame intervals") + func marginFloorAtHighFrameRate() async throws { + let r = Self.rig(frameRate: 60) + defer { r.decoder.close() } + #expect(Double(DualLayerVideoDecoder.deadlineMarginFrames) / 60 < DualLayerVideoDecoder.deadlineMarginFloor) + let n: Int64 = 252 + let due = Self.pts(n).seconds - DualLayerVideoDecoder.deadlineMarginFloor + r.clocks.seconds = due - 0.02 + let bl = try Self.feedBase(r, n) + r.clocks.uptime += Self.pastGrace + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1, "120 ms ahead: not due") + // 80 ms before the deadline: past the 50 ms three frames would give, inside the 100 ms floor. + r.clocks.seconds = Self.pts(n).seconds - 0.08 + r.decoder.runDeadlineSweep() + try await waitFor { r.deliveries.count == 1 } + #expect(r.deliveries.all.first?.buffer === bl) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 0) + } + + // MARK: - 6. A held clock: a preroll, and a pause + + @Test("a paused clock does not release a waiting frame after the margin; only the long bound does") + func pausedClockIsNotAPreroll() async throws { + let r = Self.rig(running: false) // held, no start pending + defer { r.decoder.close() } + r.clocks.seconds = 10 + let bl = try Self.feedBase(r, 252) + r.clocks.uptime += Self.margin * 4 + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1, "a pause is not late") + r.clocks.uptime += DualLayerVideoDecoder.heldClockWallBound - Self.margin * 4 - 0.001 + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1) + r.clocks.uptime += 0.002 + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 0) + try await waitFor { r.deliveries.count == 1 } + #expect(r.deliveries.all.first?.buffer === bl) + } + + // MARK: - 7. Sweep timing + + @Test("the next sweep is armed for the earliest due time, and replaced by an earlier one") + func sweepIsArmedForTheEarliestDueTime() async throws { + let r = Self.rig() + defer { r.decoder.close() } + let a: Int64 = 252, b: Int64 = 249 + let dueA = Self.pts(a).seconds - Self.margin + r.clocks.seconds = dueA - 0.2 + _ = try Self.feedBase(r, a) + let first = try #require(r.decoder.armedSweepFireUptimeForTesting) + #expect(abs((first - r.clocks.uptime) - 0.2) < 0.002, "not the 5 ms poll of a waiting frame") + // B shows 125 ms before A but takes A's place behind it, so A is due 125 ms sooner. + _ = try Self.feedBase(r, b) + let second = try #require(r.decoder.armedSweepFireUptimeForTesting) + #expect(abs((second - r.clocks.uptime) - 0.075) < 0.002) + #expect(second < first) + } + + @Test("a sweep that finds nothing waiting arms nothing") + func sweepStopsWhenNothingWaits() async throws { + let r = Self.rig() + defer { r.decoder.close() } + r.clocks.seconds = 10 + #expect(r.decoder.armedSweepFireUptimeForTesting == nil) + r.decoder.runDeadlineSweep() + #expect(r.decoder.armedSweepFireUptimeForTesting == nil) + } + + /// An arrival that does not queue still puts a waiting frame behind a frame that shows earlier, and + /// the sweep armed for the waiting frame's old time must be replaced by an earlier one. + private enum Arrival { case composes, absentInAU, missingRPU } + + private func rearmsOnArrival(_ arrival: Arrival) async throws { + let r = Self.rig() + defer { r.decoder.close() } + let a: Int64 = 252, b: Int64 = 249 + let dueA = Self.pts(a).seconds - Self.margin + r.clocks.seconds = dueA - 0.2 + _ = try Self.feedBase(r, a) + let first = try #require(r.decoder.armedSweepFireUptimeForTesting) + #expect(abs((first - r.clocks.uptime) - 0.2) < 0.002) + + // B shows 125 ms before A, takes a later ticket and is handled without queueing. + switch arrival { + case .composes: + r.decoder.handleEnhancementLayerFrame(elBuffer: try Self.enhancementLayer(), pts: Self.pts(b)) + _ = try Self.feedBase(r, b) + case .absentInAU: + r.decoder.noteEnhancementAbsentForTesting(pts: Self.pts(b)) + _ = try Self.feedBase(r, b) + case .missingRPU: + r.decoder.handleEnhancementLayerFrame(elBuffer: try Self.enhancementLayer(), pts: Self.pts(b)) + r.decoder.handleBaseLayerFrame(baseBuffer: try Self.baseLayer(), pts: Self.pts(b), hdr10Data: nil) + } + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1, "only A still waits") + let second = try #require(r.decoder.armedSweepFireUptimeForTesting) + #expect(abs((second - r.clocks.uptime) - 0.075) < 0.002, "armed for A's new, earlier due time") + #expect(second < first) + + // With no further arrival the timer alone releases A at that time. + r.clocks.uptime += 0.08 + r.clocks.seconds = Self.pts(b).seconds - Self.margin + try await waitFor { r.deliveries.count == 2 } + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 0) + } + + @Test("a frame that composes on arrival and moves a waiting frame's due time earlier re-arms the sweep") + func composingArrivalRearmsTheSweep() async throws { try await rearmsOnArrival(.composes) } + + @Test("a frame released as having no enhancement layer in its access unit re-arms the sweep") + func absentArrivalRearmsTheSweep() async throws { try await rearmsOnArrival(.absentInAU) } + + @Test("a frame released for missing RPU metadata re-arms the sweep") + func missingRPUArrivalRearmsTheSweep() async throws { try await rearmsOnArrival(.missingRPU) } + + // MARK: - 7b. No grace past the presentation time, and a cap on the grace + + @Test("a frame already at or past its presentation time is released on arrival: a late partner is late anyway") + func noGraceOnceThePresentationTimeHasPassed() async throws { + let r = Self.rig() + defer { r.decoder.close() } + let n: Int64 = 252 + r.clocks.seconds = Self.pts(n).seconds + 0.01 + let bl = try Self.feedBase(r, n) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 0, "no grace") + try await waitFor { r.deliveries.count == 1 } + #expect(r.deliveries.all.first?.buffer === bl) + + // One that is inside the margin but not yet at its time keeps the grace. + let m = n + 1 + r.clocks.seconds = Self.pts(m).seconds - 0.02 + _ = try Self.feedBase(r, m) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1) + // The sweep for it is not armed for "now" either: it waits out the grace. + let fire = try #require(r.decoder.armedSweepFireUptimeForTesting) + #expect(fire - r.clocks.uptime > 0.01) + } + + @Test("a frame whose presentation time falls inside its grace is released then, not when the grace is over") + func sweepIsArmedForThePresentationTimeInsideTheGrace() async throws { + let r = Self.rig() // 24 fps: grace 41.7 ms + defer { r.decoder.close() } + let n: Int64 = 252 + r.clocks.seconds = Self.pts(n).seconds - 0.02 // inside the margin, 20 ms short of its time + let bl = try Self.feedBase(r, n) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1) + // The sweep is armed for the presentation time (20 ms), not the end of the grace (41.7 ms). + let fire = try #require(r.decoder.armedSweepFireUptimeForTesting) + #expect(fire - r.clocks.uptime < 0.03, "armed for the presentation time, not the grace end") + // Arrivals stall; the sweep fires at the presentation time, well inside the grace. + r.clocks.seconds += 0.02 + r.clocks.uptime += 0.02 + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 0) + try await waitFor { r.deliveries.count == 1 } + #expect(r.deliveries.all.first?.buffer === bl) + } + + @Test("the grace is capped at 50 ms however slow the frame rate") + func graceIsCapped() async throws { + let r = Self.rig(frameRate: 10) // 100 ms per frame; margin deadlineMarginFrames x 100 ms + defer { r.decoder.close() } + #expect(DualLayerVideoDecoder.enhancementGraceCap == 0.05) + let n: Int64 = 252 + let margin = Double(DualLayerVideoDecoder.deadlineMarginFrames) / 10 + r.clocks.seconds = Self.pts(n).seconds - margin + 0.02 // inside the margin, short of its time + let bl = try Self.feedBase(r, n) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1) + r.clocks.uptime += 0.049 + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1, "inside the cap") + r.clocks.uptime += 0.002 + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 0, "past 50 ms, not past a frame interval") + try await waitFor { r.deliveries.count == 1 } + #expect(r.deliveries.all.first?.buffer === bl) + } + + // MARK: - 8. Flush and seek + + @Test("a flush with a frame waiting clears the absence notes, pauses the sweep while draining, and the sweep resumes after") + func flushWithAWaitingFrame() async throws { + let r = Self.rig() + defer { r.decoder.close() } + let n: Int64 = 252 + r.clocks.seconds = Self.pts(n).seconds - 5 + _ = try Self.feedBase(r, n) + r.decoder.noteEnhancementAbsentForTesting(pts: Self.pts(300)) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1) + #expect(r.decoder.armedSweepFireUptimeForTesting != nil) + #expect(r.decoder.absentEnhancementKeyCountForTesting == 1) + + // While the sessions drain, the frame is long past due by the clock, but a sweep leaves it + // alone and arms nothing. + nonisolated(unsafe) var drainingObserved = false + nonisolated(unsafe) var releasedWhileDraining = -1 + nonisolated(unsafe) var armedWhileDraining = true + nonisolated(unsafe) var absentWhileDraining = -1 + r.decoder.flushRaceHookForTesting = { [weak decoder = r.decoder, deliveries = r.deliveries, clocks = r.clocks] in + guard let decoder else { return } + drainingObserved = decoder.stateSnapshotForTesting.draining + absentWhileDraining = decoder.absentEnhancementKeyCountForTesting + clocks.seconds = Self.pts(n).seconds + 10 + clocks.uptime += 5 + decoder.runDeadlineSweep() + releasedWhileDraining = deliveries.count + armedWhileDraining = decoder.armedSweepFireUptimeForTesting != nil + } + r.decoder.flush() + r.decoder.flushRaceHookForTesting = nil + #expect(drainingObserved) + #expect(absentWhileDraining == 0, "cleared in the first critical section") + #expect(releasedWhileDraining == 0, "a pre-seek frame is not released while draining") + #expect(!armedWhileDraining) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 0) + #expect(r.decoder.armedSweepFireUptimeForTesting == nil) + + // After the flush a stale note does not bypass, and a waiting frame is swept again. + let m: Int64 = 300 + r.clocks.seconds = Self.pts(m).seconds - 5 + let waiting = try Self.feedBase(r, m) + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1, "the pre-flush absence note is gone") + #expect(r.decoder.armedSweepFireUptimeForTesting != nil, "the sweep resumes with the first waiting frame") + r.clocks.uptime += Self.pastGrace + r.clocks.seconds = Self.pts(m).seconds - Self.margin + r.decoder.runDeadlineSweep() + try await waitFor { r.deliveries.count == 1 } + #expect(r.deliveries.all.first?.buffer === waiting) + } + + @Test("a clock sample taken before a flush is discarded: it cannot release a frame that arrived after it") + func staleClockSampleAcrossAFlush() async throws { + let r = Self.rig() + defer { r.decoder.close() } + // The provider flushes the decoder and feeds a post-flush frame in the middle of the sweep's + // own sample, then reports a time far past that frame's release and moves wall time on. + final class Switches: @unchecked Sendable { var armed = false; var first = true } + let switches = Switches() + let n: Int64 = 252 + let decoder = r.decoder + let clocks = r.clocks + let feed: @Sendable () -> Void = { + decoder.seedRPUForTesting(pts: Self.pts(n), params: Self.params(), displayMetadataNAL: nil) + if let bl = Self.buffer(width: 64, height: 64) { + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(n), hdr10Data: nil) + } + } + decoder.setRenderClock({ + if switches.armed, switches.first { + switches.first = false + decoder.flush() + feed() + clocks.uptime += 1 + return RenderClockSample(seconds: Self.pts(n).seconds + 10, running: true) + } + return RenderClockSample(seconds: clocks.seconds, running: clocks.running) + }, frameRate: 24) + clocks.seconds = Self.pts(n).seconds - 5 + _ = try Self.feedBase(r, n) + switches.armed = true + r.decoder.runDeadlineSweep() + #expect(r.decoder.stateSnapshotForTesting.blQueueCount == 1, "the stale sample must not release the new frame") + #expect(r.deliveries.count == 0) + } + + // MARK: - 9. Counters + + @Test("each release rule has its own counter in the 1 Hz summary") + func summaryCountsEachRule() async throws { + let r = Self.rig() + defer { r.decoder.close() } + nonisolated(unsafe) var now: TimeInterval = 100 + nonisolated(unsafe) var lines: [String] = [] + r.decoder.summaryTimestampProviderForTesting = { now } + r.decoder.summarySinkForTesting = { line, _ in lines.append(line) } + + // In the access unit. + r.decoder.noteEnhancementAbsentForTesting(pts: Self.pts(240)) + _ = try Self.feedBase(r, 240) + // Deadline. + r.clocks.seconds = 0 + _ = try Self.feedBase(r, 252) + r.clocks.uptime += Self.pastGrace + r.clocks.seconds = Self.pts(252).seconds - Self.margin + r.decoder.runDeadlineSweep() + // Held clock, start pending. + r.clocks.running = false + r.clocks.prerolling = true + _ = try Self.feedBase(r, 260) + r.clocks.uptime += Self.margin + r.decoder.runDeadlineSweep() + // Backstop. + r.clocks.running = true + r.clocks.prerolling = false + r.clocks.seconds = 0 + for i in 0...Int64(DualLayerVideoDecoder.maxDeadlineHoldFrames) { _ = try Self.feedBase(r, 2400 + i) } + try await waitFor { r.deliveries.count == 4 } + + now = 101.5 + r.decoder.maybeEmitSummary() + let line = try #require(lines.last) + for expected in ["bypassNoELInAU=1", "bypassDeadline=1", "bypassHeldWall=1", "bypassBackstop=1", + "bypassNoELDecoder=0", "bypassEndOfStream=0"] { + #expect(line.contains(expected), "\(expected) in \(line)") + } + } +} + +#endif diff --git a/Tests/AetherEngineTests/DualLayerEdgeCaseTests.swift b/Tests/AetherEngineTests/DualLayerEdgeCaseTests.swift index 23553ce9e..4a91e5e0b 100644 --- a/Tests/AetherEngineTests/DualLayerEdgeCaseTests.swift +++ b/Tests/AetherEngineTests/DualLayerEdgeCaseTests.swift @@ -12,7 +12,7 @@ import AetherLibavformat /// layers, the pixel-rate budget at its boundary, 1001-based timebases, and seeks that land on 0, on a /// replayed range, or in a burst. Everything is synthetic: tagged pixel buffers into the frame handlers, /// a recording composer, no media and no GPU. -@Suite(.serialized, .timeLimit(.minutes(1))) +@Suite(.serialized, .offCooperativePool, .timeLimit(.minutes(1))) struct DualLayerEdgeCaseTests { // MARK: - Fixtures diff --git a/Tests/AetherEngineTests/DualLayerGpuSlotWaitTests.swift b/Tests/AetherEngineTests/DualLayerGpuSlotWaitTests.swift index 7f9ebab87..2837b4af8 100644 --- a/Tests/AetherEngineTests/DualLayerGpuSlotWaitTests.swift +++ b/Tests/AetherEngineTests/DualLayerGpuSlotWaitTests.swift @@ -6,309 +6,311 @@ import CoreVideo import Foundation @testable import AetherEngine -/// The GPU slot wait must not hold the decoder lock. -/// -/// With a composer whose in-flight limit is 1 and whose first completion is -/// held by the test, a second composition started on another thread blocks in -/// `acquireSlot()` with no lock held, so `flush()` returns promptly. On the -/// pre-fix code the wait sat inside `composeAsync` while the VideoToolbox -/// thread held the decoder lock, and `flush()` hung behind it. -@Suite(.serialized, .offCooperativePoolOneLane, .timeLimit(.minutes(5))) -struct DualLayerGpuSlotWaitTests { +extension HeavyDualLayerSuites { + /// The GPU slot wait must not hold the decoder lock. + /// + /// With a composer whose in-flight limit is 1 and whose first completion is + /// held by the test, a second composition started on another thread blocks in + /// `acquireSlot()` with no lock held, so `flush()` returns promptly. On the + /// pre-fix code the wait sat inside `composeAsync` while the VideoToolbox + /// thread held the decoder lock, and `flush()` hung behind it. + @Suite(.serialized, .offCooperativePool, .timeLimit(.minutes(5))) + struct DualLayerGpuSlotWaitTests { - /// Fake composer with an in-flight limit of 1. The first composition's - /// completion is held until the test releases it; the slot is held with - /// it, the way the real composer holds the slot until the command buffer - /// retires. - final class GatedFakeComposer: FelComposing, @unchecked Sendable { - private let slots = DispatchSemaphore(value: 1) - private let lock = NSLock() - private var held: (@Sendable (CVPixelBuffer?) -> Void)? - private var heldBase: CVPixelBuffer? - private(set) var acquireCalls = 0 - private(set) var completedCount = 0 - var acquireCallCount: Int { lock.lock(); defer { lock.unlock() }; return acquireCalls } + /// Fake composer with an in-flight limit of 1. The first composition's + /// completion is held until the test releases it; the slot is held with + /// it, the way the real composer holds the slot until the command buffer + /// retires. + final class GatedFakeComposer: FelComposing, @unchecked Sendable { + private let slots = DispatchSemaphore(value: 1) + private let lock = NSLock() + private var held: (@Sendable (CVPixelBuffer?) -> Void)? + private var heldBase: CVPixelBuffer? + private(set) var acquireCalls = 0 + private(set) var completedCount = 0 + var acquireCallCount: Int { lock.lock(); defer { lock.unlock() }; return acquireCalls } - func acquireSlot() { - lock.lock(); acquireCalls += 1; lock.unlock() - slots.wait() - } + func acquireSlot() { + lock.lock(); acquireCalls += 1; lock.unlock() + slots.wait() + } - func releaseSlot() { slots.signal() } + func releaseSlot() { slots.signal() } - func composeAsync( - baseBuffer: CVPixelBuffer, - enhancementBuffer: CVPixelBuffer, - params: DoviMetalParams, - frameIndex: UInt32, - completion: @escaping @Sendable (CVPixelBuffer?) -> Void - ) { - acquireSlot() - composeAsyncWithAcquiredSlot( - baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, - params: params, frameIndex: frameIndex, completion: completion) - } + func composeAsync( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void + ) { + acquireSlot() + composeAsyncWithAcquiredSlot( + baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, + params: params, frameIndex: frameIndex, completion: completion) + } - func composeAsyncWithAcquiredSlot( - baseBuffer: CVPixelBuffer, - enhancementBuffer: CVPixelBuffer, - params: DoviMetalParams, - frameIndex: UInt32, - completion: @escaping @Sendable (CVPixelBuffer?) -> Void - ) { - lock.lock() - let isFirst = held == nil && completedCount == 0 - if isFirst { - held = completion - heldBase = baseBuffer + func composeAsyncWithAcquiredSlot( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void + ) { + lock.lock() + let isFirst = held == nil && completedCount == 0 + if isFirst { + held = completion + heldBase = baseBuffer + } + lock.unlock() + if !isFirst { + // Later frames finish synchronously through the base layer once + // the test has released the first slot. Ticket ordering in + // `deliver` is what the ordering test exercises, not the hop. + slots.signal() + lock.lock(); completedCount += 1; lock.unlock() + completion(baseBuffer) + } } - lock.unlock() - if !isFirst { - // Later frames finish synchronously through the base layer once - // the test has released the first slot. Ticket ordering in - // `deliver` is what the ordering test exercises, not the hop. + + /// Releases the held first composition: delivers its base layer as the + /// composed result and frees the slot. + func releaseFirst() { + let completion: (@Sendable (CVPixelBuffer?) -> Void)? + let base: CVPixelBuffer? + lock.lock() + completion = held; base = heldBase + held = nil; heldBase = nil + completedCount += 1 + lock.unlock() + if let completion { + completion(base) + } slots.signal() - lock.lock(); completedCount += 1; lock.unlock() - completion(baseBuffer) } + + func flush() {} } - /// Releases the held first composition: delivers its base layer as the - /// composed result and frees the slot. - func releaseFirst() { - let completion: (@Sendable (CVPixelBuffer?) -> Void)? - let base: CVPixelBuffer? - lock.lock() - completion = held; base = heldBase - held = nil; heldBase = nil - completedCount += 1 - lock.unlock() - if let completion { - completion(base) - } - slots.signal() + private static let frameTicks: Int64 = 3754 + private static func pts(_ frame: Int64) -> CMTime { + CMTime(value: frame * frameTicks, timescale: 90000) } - func flush() {} - } + private static func makeBuffer(width: Int, height: Int) -> CVPixelBuffer? { + var out: CVPixelBuffer? + let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, + kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, + [kCVPixelBufferMetalCompatibilityKey: true, + kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary] as CFDictionary, + &out) + return status == kCVReturnSuccess ? out : nil + } - private static let frameTicks: Int64 = 3754 - private static func pts(_ frame: Int64) -> CMTime { - CMTime(value: frame * frameTicks, timescale: 90000) - } + private final class Deliveries: @unchecked Sendable { + private let lock = NSLock() + private(set) var points: [CMTime] = [] + private var metadata: [Data?] = [] + func record(_ buffer: CVPixelBuffer, _ pts: CMTime, _ hdr10: Data?) { + let payload = CVBufferCopyAttachment(buffer, DoviDisplayMetadata.rpuDataKey as CFString, nil) as? Data + lock.lock(); points.append(pts); metadata.append(payload); lock.unlock() + } + var count: Int { lock.lock(); defer { lock.unlock() }; return points.count } + var metadataPayloads: [Data?] { lock.lock(); defer { lock.unlock() }; return metadata } + } - private static func makeBuffer(width: Int, height: Int) -> CVPixelBuffer? { - var out: CVPixelBuffer? - let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, - kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, - [kCVPixelBufferMetalCompatibilityKey: true, - kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary] as CFDictionary, - &out) - return status == kCVReturnSuccess ? out : nil - } + /// Set once by a worker thread when it has finished; read by the test. + private final class Flag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + func set() { lock.lock(); value = true; lock.unlock() } + var isSet: Bool { lock.lock(); defer { lock.unlock() }; return value } + } - private final class Deliveries: @unchecked Sendable { - private let lock = NSLock() - private(set) var points: [CMTime] = [] - private var metadata: [Data?] = [] - func record(_ buffer: CVPixelBuffer, _ pts: CMTime, _ hdr10: Data?) { - let payload = CVBufferCopyAttachment(buffer, DoviDisplayMetadata.rpuDataKey as CFString, nil) as? Data - lock.lock(); points.append(pts); metadata.append(payload); lock.unlock() + /// Baton-pass box: the test thread stops touching the buffer once it is + /// handed to the background thread, mirroring the decoder's own handoff. + private struct SendablePixelBuffer: @unchecked Sendable { + let value: CVPixelBuffer } - var count: Int { lock.lock(); defer { lock.unlock() }; return points.count } - var metadataPayloads: [Data?] { lock.lock(); defer { lock.unlock() }; return metadata } - } - /// Set once by a worker thread when it has finished; read by the test. - private final class Flag: @unchecked Sendable { - private let lock = NSLock() - private var value = false - func set() { lock.lock(); value = true; lock.unlock() } - var isSet: Bool { lock.lock(); defer { lock.unlock() }; return value } - } + @Test("a second composition blocked on the GPU slot does not stall flush()") + func blockedSlotDoesNotStallFlush() async throws { + let bl0 = try #require(Self.makeBuffer(width: 64, height: 64)) + let el0 = try #require(Self.makeBuffer(width: 32, height: 32)) + let bl1 = try #require(Self.makeBuffer(width: 64, height: 64)) + let el1 = try #require(Self.makeBuffer(width: 32, height: 32)) - /// Baton-pass box: the test thread stops touching the buffer once it is - /// handed to the background thread, mirroring the decoder's own handoff. - private struct SendablePixelBuffer: @unchecked Sendable { - let value: CVPixelBuffer - } + let composer = GatedFakeComposer() + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: composer) + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + decoder.seedRPUForTesting(pts: Self.pts(0), params: DoviMetalParams(), displayMetadataNAL: nil) + decoder.seedRPUForTesting(pts: Self.pts(1), params: DoviMetalParams(), displayMetadataNAL: nil) - @Test("a second composition blocked on the GPU slot does not stall flush()") - func blockedSlotDoesNotStallFlush() async throws { - let bl0 = try #require(Self.makeBuffer(width: 64, height: 64)) - let el0 = try #require(Self.makeBuffer(width: 32, height: 32)) - let bl1 = try #require(Self.makeBuffer(width: 64, height: 64)) - let el1 = try #require(Self.makeBuffer(width: 32, height: 32)) + // First pair composes and holds the only slot. + decoder.handleEnhancementLayerFrame(elBuffer: el0, pts: Self.pts(0)) + decoder.handleBaseLayerFrame(baseBuffer: bl0, pts: Self.pts(0), hdr10Data: nil) - let composer = GatedFakeComposer() - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: composer) - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - decoder.seedRPUForTesting(pts: Self.pts(0), params: DoviMetalParams(), displayMetadataNAL: nil) - decoder.seedRPUForTesting(pts: Self.pts(1), params: DoviMetalParams(), displayMetadataNAL: nil) + // Second pair on another thread: its enhancement layer queues without + // blocking, then its base layer matches and parks in acquireSlot(). + let bl1Box = SendablePixelBuffer(value: bl1) + let el1Box = SendablePixelBuffer(value: el1) + // A dedicated thread, not the global queue: other suites run concurrently and + // can saturate GCD's pool, which starved this closure past the deadline. + let workerDone = Flag() + let worker = Thread { + decoder.handleEnhancementLayerFrame(elBuffer: el1Box.value, pts: Self.pts(1)) + decoder.handleBaseLayerFrame(baseBuffer: bl1Box.value, pts: Self.pts(1), hdr10Data: nil) + workerDone.set() + } + worker.start() + // Wait until the background thread is inside the slot wait (on the + // pre-fix code it instead holds the decoder lock here, which is what + // made flush() hang). Then flush() must still return promptly. + try await waitFor { composer.acquireCallCount == 2 } - // First pair composes and holds the only slot. - decoder.handleEnhancementLayerFrame(elBuffer: el0, pts: Self.pts(0)) - decoder.handleBaseLayerFrame(baseBuffer: bl0, pts: Self.pts(0), hdr10Data: nil) + // flush() runs while the second composition is parked in `acquireSlot` and the first is held + // by the gate. Returning at all is the proof (it would wait on the decoder lock otherwise, + // and the suite's time limit would report the hang); no wall-clock bound is asserted. + decoder.flush() - // Second pair on another thread: its enhancement layer queues without - // blocking, then its base layer matches and parks in acquireSlot(). - let bl1Box = SendablePixelBuffer(value: bl1) - let el1Box = SendablePixelBuffer(value: el1) - // A dedicated thread, not the global queue: other suites run concurrently and - // can saturate GCD's pool, which starved this closure past the deadline. - let workerDone = Flag() - let worker = Thread { - decoder.handleEnhancementLayerFrame(elBuffer: el1Box.value, pts: Self.pts(1)) - decoder.handleBaseLayerFrame(baseBuffer: bl1Box.value, pts: Self.pts(1), hdr10Data: nil) - workerDone.set() + // Release the held composition and let the second finish; the flush + // retired both tickets, so neither may be delivered after it. + composer.releaseFirst() + try await waitFor { workerDone.isSet } + decoder.quiesceForTesting() + #expect(deliveries.count == 0, "frames composed before flush() must not be delivered after it") + decoder.close() } - worker.start() - // Wait until the background thread is inside the slot wait (on the - // pre-fix code it instead holds the decoder lock here, which is what - // made flush() hang). Then flush() must still return promptly. - try await waitFor { composer.acquireCallCount == 2 } - - // flush() runs while the second composition is parked in `acquireSlot` and the first is held - // by the gate. Returning at all is the proof (it would wait on the decoder lock otherwise, - // and the suite's time limit would report the hang); no wall-clock bound is asserted. - decoder.flush() - // Release the held composition and let the second finish; the flush - // retired both tickets, so neither may be delivered after it. - composer.releaseFirst() - try await waitFor { workerDone.isSet } - decoder.quiesceForTesting() - #expect(deliveries.count == 0, "frames composed before flush() must not be delivered after it") - decoder.close() - } - - @Test("a frame with no usable RPU borrows the previous frame's parameters, but only for a short run") - func missingRpuBorrowsPreviousParameters() async throws { - let composer = GatedFakeComposer() - let decoder = DualLayerVideoDecoder(composer: composer) - decoder.onFrame = { _, _, _ in } - decoder.primeForTesting(enhancementLayer: true) - let limit = DualLayerVideoDecoder.maxBorrowedParamsRun - // Packets in decode order: frame 0 has a real RPU, every later one has none. - decoder.seedRPUForTesting(pts: Self.pts(0), params: DoviMetalParams(), displayMetadataNAL: nil) - for i in 1...(limit + 1) { - decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: nil, displayMetadataNAL: nil) - } - let box = try (0...(limit + 1)).map { _ -> (SendablePixelBuffer, SendablePixelBuffer) in - let bl = try #require(Self.makeBuffer(width: 64, height: 64)) - let el = try #require(Self.makeBuffer(width: 32, height: 32)) - return (SendablePixelBuffer(value: bl), SendablePixelBuffer(value: el)) - } - let workerDone = Flag() - let worker = Thread { - for (i, pair) in box.enumerated() { - decoder.handleEnhancementLayerFrame(elBuffer: pair.1.value, pts: Self.pts(Int64(i))) - decoder.handleBaseLayerFrame(baseBuffer: pair.0.value, pts: Self.pts(Int64(i)), hdr10Data: nil) + @Test("a frame with no usable RPU borrows the previous frame's parameters, but only for a short run") + func missingRpuBorrowsPreviousParameters() async throws { + let composer = GatedFakeComposer() + let decoder = DualLayerVideoDecoder(composer: composer) + decoder.onFrame = { _, _, _ in } + decoder.primeForTesting(enhancementLayer: true) + let limit = DualLayerVideoDecoder.maxBorrowedParamsRun + // Packets in decode order: frame 0 has a real RPU, every later one has none. + decoder.seedRPUForTesting(pts: Self.pts(0), params: DoviMetalParams(), displayMetadataNAL: nil) + for i in 1...(limit + 1) { + decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: nil, displayMetadataNAL: nil) + } + let box = try (0...(limit + 1)).map { _ -> (SendablePixelBuffer, SendablePixelBuffer) in + let bl = try #require(Self.makeBuffer(width: 64, height: 64)) + let el = try #require(Self.makeBuffer(width: 32, height: 32)) + return (SendablePixelBuffer(value: bl), SendablePixelBuffer(value: el)) + } + let workerDone = Flag() + let worker = Thread { + for (i, pair) in box.enumerated() { + decoder.handleEnhancementLayerFrame(elBuffer: pair.1.value, pts: Self.pts(Int64(i))) + decoder.handleBaseLayerFrame(baseBuffer: pair.0.value, pts: Self.pts(Int64(i)), hdr10Data: nil) + } + workerDone.set() } - workerDone.set() + worker.start() + // Frame 0 holds the only slot. Frame 1 borrows and parks in acquireSlot (count 2). Frames after + // the borrow limit must bypass instead of reaching the composer. + try await waitFor { composer.acquireCallCount == 2 } + composer.releaseFirst() + try await waitFor { workerDone.isSet } + #expect(composer.acquireCallCount == 1 + limit) + decoder.close() } - worker.start() - // Frame 0 holds the only slot. Frame 1 borrows and parks in acquireSlot (count 2). Frames after - // the borrow limit must bypass instead of reaching the composer. - try await waitFor { composer.acquireCallCount == 2 } - composer.releaseFirst() - try await waitFor { workerDone.isSet } - #expect(composer.acquireCallCount == 1 + limit) - decoder.close() - } - @Test("borrowed parameters carry display metadata only within the borrow limit, and flush clears it") - func missingRpuBorrowsPreviousDisplayMetadata() async throws { - let composer = GatedFakeComposer() - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: composer) - defer { decoder.close() } - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - let nal: [UInt8] = [0x7C, 0x01, 0x19, 0x42, 0x03] - let payload = Data(nal.dropFirst(2)) - let limit = DualLayerVideoDecoder.maxBorrowedParamsRun - decoder.seedRPUForTesting(pts: Self.pts(0), params: DoviMetalParams(), displayMetadataNAL: nal) - for i in 1...(limit + 1) { - decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: nil, displayMetadataNAL: nil) - } + @Test("borrowed parameters carry display metadata only within the borrow limit, and flush clears it") + func missingRpuBorrowsPreviousDisplayMetadata() async throws { + let composer = GatedFakeComposer() + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: composer) + defer { decoder.close() } + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + let nal: [UInt8] = [0x7C, 0x01, 0x19, 0x42, 0x03] + let payload = Data(nal.dropFirst(2)) + let limit = DualLayerVideoDecoder.maxBorrowedParamsRun + decoder.seedRPUForTesting(pts: Self.pts(0), params: DoviMetalParams(), displayMetadataNAL: nal) + for i in 1...(limit + 1) { + decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: nil, displayMetadataNAL: nil) + } - for i in 0...(limit + 1) { - let bl = try #require(Self.makeBuffer(width: 64, height: 64)) - let el = try #require(Self.makeBuffer(width: 32, height: 32)) - let pts = Self.pts(Int64(i)) - if i.isMultiple(of: 2) { - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: pts) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: pts, hdr10Data: nil) - } else { - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: pts, hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: pts) + for i in 0...(limit + 1) { + let bl = try #require(Self.makeBuffer(width: 64, height: 64)) + let el = try #require(Self.makeBuffer(width: 32, height: 32)) + let pts = Self.pts(Int64(i)) + if i.isMultiple(of: 2) { + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: pts) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: pts, hdr10Data: nil) + } else { + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: pts, hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: pts) + } + if i == 0 { composer.releaseFirst() } } - if i == 0 { composer.releaseFirst() } - } - try await waitFor { deliveries.count >= limit + 2 } - #expect(deliveries.metadataPayloads == Array(repeating: Optional(payload), count: limit + 1) + [nil]) - #expect(composer.acquireCallCount == 1 + limit) + try await waitFor { deliveries.count >= limit + 2 } + #expect(deliveries.metadataPayloads == Array(repeating: Optional(payload), count: limit + 1) + [nil]) + #expect(composer.acquireCallCount == 1 + limit) - decoder.seedRPUForTesting(pts: Self.pts(10), params: DoviMetalParams(), displayMetadataNAL: nal) - let bl = try #require(Self.makeBuffer(width: 64, height: 64)) - let el = try #require(Self.makeBuffer(width: 32, height: 32)) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(10), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(10)) - try await waitFor { deliveries.count >= limit + 3 } - #expect(deliveries.count == limit + 3) - decoder.flush() - for i in 11...12 { - decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: nil, displayMetadataNAL: nil) + decoder.seedRPUForTesting(pts: Self.pts(10), params: DoviMetalParams(), displayMetadataNAL: nal) let bl = try #require(Self.makeBuffer(width: 64, height: 64)) let el = try #require(Self.makeBuffer(width: 32, height: 32)) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(10), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(10)) + try await waitFor { deliveries.count >= limit + 3 } + #expect(deliveries.count == limit + 3) + decoder.flush() + for i in 11...12 { + decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: nil, displayMetadataNAL: nil) + let bl = try #require(Self.makeBuffer(width: 64, height: 64)) + let el = try #require(Self.makeBuffer(width: 32, height: 32)) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) + } + try await waitFor { deliveries.count >= limit + 5 } + #expect(deliveries.metadataPayloads == Array(repeating: Optional(payload), count: limit + 1) + [nil, payload, nil, nil]) + #expect(composer.acquireCallCount == 2 + limit) } - try await waitFor { deliveries.count >= limit + 5 } - #expect(deliveries.metadataPayloads == Array(repeating: Optional(payload), count: limit + 1) + [nil, payload, nil, nil]) - #expect(composer.acquireCallCount == 2 + limit) - } - @Test("ordering still holds across two gated compositions") - func gatedCompositionsDeliverInOrder() async throws { - let bl0 = try #require(Self.makeBuffer(width: 64, height: 64)) - let el0 = try #require(Self.makeBuffer(width: 32, height: 32)) - let bl1 = try #require(Self.makeBuffer(width: 64, height: 64)) - let el1 = try #require(Self.makeBuffer(width: 32, height: 32)) + @Test("ordering still holds across two gated compositions") + func gatedCompositionsDeliverInOrder() async throws { + let bl0 = try #require(Self.makeBuffer(width: 64, height: 64)) + let el0 = try #require(Self.makeBuffer(width: 32, height: 32)) + let bl1 = try #require(Self.makeBuffer(width: 64, height: 64)) + let el1 = try #require(Self.makeBuffer(width: 32, height: 32)) - let composer = GatedFakeComposer() - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: composer) - defer { decoder.close() } - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - decoder.seedRPUForTesting(pts: Self.pts(0), params: DoviMetalParams(), displayMetadataNAL: nil) - decoder.seedRPUForTesting(pts: Self.pts(1), params: DoviMetalParams(), displayMetadataNAL: nil) + let composer = GatedFakeComposer() + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: composer) + defer { decoder.close() } + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + decoder.seedRPUForTesting(pts: Self.pts(0), params: DoviMetalParams(), displayMetadataNAL: nil) + decoder.seedRPUForTesting(pts: Self.pts(1), params: DoviMetalParams(), displayMetadataNAL: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el0, pts: Self.pts(0)) - decoder.handleBaseLayerFrame(baseBuffer: bl0, pts: Self.pts(0), hdr10Data: nil) - // Second composition blocks on the held slot on another thread. - let bl1Box = SendablePixelBuffer(value: bl1) - let el1Box = SendablePixelBuffer(value: el1) - // A dedicated thread, not the global queue: other suites run concurrently and - // can saturate GCD's pool, which starved this closure past the deadline. - let worker = Thread { - decoder.handleEnhancementLayerFrame(elBuffer: el1Box.value, pts: Self.pts(1)) - decoder.handleBaseLayerFrame(baseBuffer: bl1Box.value, pts: Self.pts(1), hdr10Data: nil) - } - worker.start() - try await waitFor { composer.acquireCallCount == 2 } - composer.releaseFirst() + decoder.handleEnhancementLayerFrame(elBuffer: el0, pts: Self.pts(0)) + decoder.handleBaseLayerFrame(baseBuffer: bl0, pts: Self.pts(0), hdr10Data: nil) + // Second composition blocks on the held slot on another thread. + let bl1Box = SendablePixelBuffer(value: bl1) + let el1Box = SendablePixelBuffer(value: el1) + // A dedicated thread, not the global queue: other suites run concurrently and + // can saturate GCD's pool, which starved this closure past the deadline. + let worker = Thread { + decoder.handleEnhancementLayerFrame(elBuffer: el1Box.value, pts: Self.pts(1)) + decoder.handleBaseLayerFrame(baseBuffer: bl1Box.value, pts: Self.pts(1), hdr10Data: nil) + } + worker.start() + try await waitFor { composer.acquireCallCount == 2 } + composer.releaseFirst() - try await waitFor { deliveries.count >= 2 } - #expect(deliveries.count == 2) - let points = deliveries.points - if points.count == 2 { - #expect(CMTimeCompare(points[0], Self.pts(0)) == 0) - #expect(CMTimeCompare(points[1], Self.pts(1)) == 0) + try await waitFor { deliveries.count >= 2 } + #expect(deliveries.count == 2) + let points = deliveries.points + if points.count == 2 { + #expect(CMTimeCompare(points[0], Self.pts(0)) == 0) + #expect(CMTimeCompare(points[1], Self.pts(1)) == 0) + } } } } diff --git a/Tests/AetherEngineTests/DualLayerPictureAlignmentSyntheticTests.swift b/Tests/AetherEngineTests/DualLayerPictureAlignmentSyntheticTests.swift index 5743c166d..df4870302 100644 --- a/Tests/AetherEngineTests/DualLayerPictureAlignmentSyntheticTests.swift +++ b/Tests/AetherEngineTests/DualLayerPictureAlignmentSyntheticTests.swift @@ -169,5 +169,55 @@ struct DualLayerPictureAlignmentSyntheticTests { let r = Self.run(&a, Self.unit(bl: Self.slice(T.trailR, poc: 21), el: Self.slice(T.trailR, poc: 6)), pts: 21000) #expect(r == Result(pts: 6000, placed: true)) } + + // MARK: - Slots missing from the base layer's times + + @Test("an enhancement slot below the latest base slot that no base picture filled pairs by access unit") + func missingPastSlotKeepsUnitTime() { + var a = Self.seeded() + a.reset() + // Base POCs 0, 1, 2, then 4: slot 3 is never filled (a picture the base layer did not number). + _ = Self.run(&a, Self.unit(bl: Self.slice(T.idrNLP, poc: 0), el: Self.slice(T.idrNLP, poc: 0), parameterSets: true), pts: 0) + for i in [1, 2, 4] { + _ = Self.run(&a, Self.unit(bl: Self.slice(T.trailR, poc: i), el: Self.slice(T.trailR, poc: i)), pts: Int64(i) * 1000) + } + // Unit 4: base POC 5 (latest slot 5, time 5000); the enhancement layer shows POC 3, whose slot + // is below the latest and has no base time. Extrapolating along the origin-to-latest line + // (1000 per slot) would give 3000, a time no base frame has; the unit keeps its own 5000. + let r = Self.run(&a, Self.unit(bl: Self.slice(T.trailR, poc: 5), el: Self.slice(T.trailR, poc: 3)), pts: 5000) + #expect(r == Result(pts: 5000, placed: false)) + #expect(a.reorderedUnits == 0) + } + + @Test("when the base layer reorders, an enhancement slot below the latest decoded one is still to come and is extrapolated") + func reorderingBaseKeepsExtrapolatingBelowLatest() { + var a = Self.seeded() + a.reset() + // Base decode order 0, 8, 4 (display slots), the enhancement layer in plain order: its slot at + // unit k is k, one time per slot of 1000. + _ = Self.run(&a, Self.unit(bl: Self.slice(T.idrNLP, poc: 0), el: Self.slice(T.idrNLP, poc: 0), parameterSets: true), pts: 0) + // Unit 1: the base layer has not shown a B-frame yet, so slot 1 has no time and pairs by access unit. + let first = Self.run(&a, Self.unit(bl: Self.slice(T.trailR, poc: 8), el: Self.slice(T.trailR, poc: 1)), pts: 8000) + #expect(first == Result(pts: 8000, placed: false)) + // Unit 2: base slot 4 sits below slot 8, which the base layer decoded first, so it reorders. The + // enhancement slot 2 is below the latest decoded slot (4) but its base picture is still to come. + let r = Self.run(&a, Self.unit(bl: Self.slice(T.trailR, poc: 4), el: Self.slice(T.trailR, poc: 2)), pts: 4000) + #expect(r == Result(pts: 2000, placed: true)) + #expect(a.reorderedUnits == 1) + } + + @Test("an enhancement slot beyond the latest base slot is extrapolated from the time per slot") + func futureSlotExtrapolates() { + var a = Self.seeded() + a.reset() + _ = Self.run(&a, Self.unit(bl: Self.slice(T.idrNLP, poc: 0), el: Self.slice(T.idrNLP, poc: 0), parameterSets: true), pts: 0) + for i in 1...2 { + _ = Self.run(&a, Self.unit(bl: Self.slice(T.trailR, poc: i), el: Self.slice(T.trailR, poc: i)), pts: Int64(i) * 1000) + } + // Unit 3: base POC 3 (time 3000), enhancement POC 4: one slot ahead at 1000 per slot. + let r = Self.run(&a, Self.unit(bl: Self.slice(T.trailR, poc: 3), el: Self.slice(T.trailR, poc: 4)), pts: 3000) + #expect(r == Result(pts: 4000, placed: true)) + #expect(a.reorderedUnits == 1) + } } #endif diff --git a/Tests/AetherEngineTests/DualLayerSeekRealignmentTests.swift b/Tests/AetherEngineTests/DualLayerSeekRealignmentTests.swift index e67dfa4e2..7f9260dd7 100644 --- a/Tests/AetherEngineTests/DualLayerSeekRealignmentTests.swift +++ b/Tests/AetherEngineTests/DualLayerSeekRealignmentTests.swift @@ -7,304 +7,306 @@ import Foundation import AetherLibavformat @_spi(Diagnostics) @testable import AetherEngine -/// A seek on the Profile 7 FEL route has to flush and realign both layers on the same picture -/// (the maintainer's parity requirement). The AV1/VP9 software path has one decoder to flush; here -/// there are two VideoToolbox sessions, the composer, the ordered delivery, the two frame queues and -/// the side-data ring, and a seek lands on timestamps the pre-seek frames may already have used (a -/// backward seek replays the same PTS keys). These tests drive the frame handlers with tagged -/// synthetic buffers and check what the composer was asked to pair and what reached `onFrame`. -@Suite(.serialized, .offCooperativePoolOneLane, .timeLimit(.minutes(5))) -struct DualLayerSeekRealignmentTests { - - // MARK: - Fixtures - - private static let frameTicks: Int64 = 3754 - private static func pts(_ frame: Int64) -> CMTime { CMTime(value: frame * frameTicks, timescale: 90000) } - - private static func buffer(width: Int, height: Int) -> CVPixelBuffer? { - var out: CVPixelBuffer? - let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, - kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, - [kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary] as CFDictionary, - &out) - return status == kCVReturnSuccess ? out : nil - } - - private static func params() -> DoviMetalParams { - var p = DoviMetalParams() - p.blWidth = 64; p.blHeight = 64; p.elWidth = 32; p.elHeight = 32 - return p - } +extension HeavyDualLayerSuites { + /// A seek on the Profile 7 FEL route has to flush and realign both layers on the same picture + /// (the maintainer's parity requirement). The AV1/VP9 software path has one decoder to flush; here + /// there are two VideoToolbox sessions, the composer, the ordered delivery, the two frame queues and + /// the side-data ring, and a seek lands on timestamps the pre-seek frames may already have used (a + /// backward seek replays the same PTS keys). These tests drive the frame handlers with tagged + /// synthetic buffers and check what the composer was asked to pair and what reached `onFrame`. + @Suite(.serialized, .offCooperativePool, .timeLimit(.minutes(5))) + struct DualLayerSeekRealignmentTests { + + // MARK: - Fixtures + + private static let frameTicks: Int64 = 3754 + private static func pts(_ frame: Int64) -> CMTime { CMTime(value: frame * frameTicks, timescale: 90000) } + + private static func buffer(width: Int, height: Int) -> CVPixelBuffer? { + var out: CVPixelBuffer? + let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, + kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, + [kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary] as CFDictionary, + &out) + return status == kCVReturnSuccess ? out : nil + } - private struct Pair { let base: CVPixelBuffer; let enhancement: CVPixelBuffer } - - /// Records every pairing the decoder asks for and counts flushes. Completes at once with the - /// base buffer, unless `holdFirst` is set: then the first composition's completion is parked - /// until `releaseHeld()`, the way a command buffer still on the GPU holds it. - private final class FakeComposer: FelComposing, @unchecked Sendable { - private let lock = NSLock() - private var _pairs: [Pair] = [] - private var _flushes = 0 - private var held: (@Sendable (CVPixelBuffer?) -> Void)? - private var heldBase: CVPixelBuffer? - private let holdFirst: Bool - - init(holdFirst: Bool = false) { self.holdFirst = holdFirst } - - var pairs: [Pair] { lock.lock(); defer { lock.unlock() }; return _pairs } - var flushCount: Int { lock.lock(); defer { lock.unlock() }; return _flushes } - - func acquireSlot() {} - func releaseSlot() {} - func flush() { lock.lock(); _flushes += 1; lock.unlock() } - func composeAsync(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, params: DoviMetalParams, - frameIndex: UInt32, completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { - composeAsyncWithAcquiredSlot(baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, - params: params, frameIndex: frameIndex, completion: completion) + private static func params() -> DoviMetalParams { + var p = DoviMetalParams() + p.blWidth = 64; p.blHeight = 64; p.elWidth = 32; p.elHeight = 32 + return p } - func composeAsyncWithAcquiredSlot(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, - params: DoviMetalParams, frameIndex: UInt32, - completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { - lock.lock() - let first = _pairs.isEmpty - _pairs.append(Pair(base: baseBuffer, enhancement: enhancementBuffer)) - if holdFirst, first { - held = completion - heldBase = baseBuffer + + private struct Pair { let base: CVPixelBuffer; let enhancement: CVPixelBuffer } + + /// Records every pairing the decoder asks for and counts flushes. Completes at once with the + /// base buffer, unless `holdFirst` is set: then the first composition's completion is parked + /// until `releaseHeld()`, the way a command buffer still on the GPU holds it. + private final class FakeComposer: FelComposing, @unchecked Sendable { + private let lock = NSLock() + private var _pairs: [Pair] = [] + private var _flushes = 0 + private var held: (@Sendable (CVPixelBuffer?) -> Void)? + private var heldBase: CVPixelBuffer? + private let holdFirst: Bool + + init(holdFirst: Bool = false) { self.holdFirst = holdFirst } + + var pairs: [Pair] { lock.lock(); defer { lock.unlock() }; return _pairs } + var flushCount: Int { lock.lock(); defer { lock.unlock() }; return _flushes } + + func acquireSlot() {} + func releaseSlot() {} + func flush() { lock.lock(); _flushes += 1; lock.unlock() } + func composeAsync(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, params: DoviMetalParams, + frameIndex: UInt32, completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { + composeAsyncWithAcquiredSlot(baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, + params: params, frameIndex: frameIndex, completion: completion) + } + func composeAsyncWithAcquiredSlot(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { + lock.lock() + let first = _pairs.isEmpty + _pairs.append(Pair(base: baseBuffer, enhancement: enhancementBuffer)) + if holdFirst, first { + held = completion + heldBase = baseBuffer + lock.unlock() + return + } lock.unlock() - return + completion(baseBuffer) } - lock.unlock() - completion(baseBuffer) - } - func releaseHeld() { - lock.lock() - let completion = held, base = heldBase - held = nil; heldBase = nil - lock.unlock() - completion?(base) + func releaseHeld() { + lock.lock() + let completion = held, base = heldBase + held = nil; heldBase = nil + lock.unlock() + completion?(base) + } } - } - /// A VideoToolbox session stand-in that only counts what the decoder does to it. - private final class FakeSession: SingleLayerVideoDecoder, @unchecked Sendable { - private let lock = NSLock() - private var _flushes = 0 - var flushCount: Int { lock.lock(); defer { lock.unlock() }; return _flushes } - var onFrame: DecodedFrameHandler? - var onFirstHDR10PlusDetected: (@Sendable () -> Void)? - var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? - var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? - var skipUntilPTS: CMTime? - var feedEpoch: UInt64 = 0 - func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws {} - func open(parameterSets: [[UInt8]], timeBase: AVRational, - onFrame: @escaping DecodedFrameHandler) throws {} - func decode(packet: UnsafeMutablePointer, epoch: UInt64?) {} - func flush() { lock.lock(); _flushes += 1; lock.unlock() } - func close() {} - } + /// A VideoToolbox session stand-in that only counts what the decoder does to it. + private final class FakeSession: SingleLayerVideoDecoder, @unchecked Sendable { + private let lock = NSLock() + private var _flushes = 0 + var flushCount: Int { lock.lock(); defer { lock.unlock() }; return _flushes } + var onFrame: DecodedFrameHandler? + var onFirstHDR10PlusDetected: (@Sendable () -> Void)? + var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? + var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? + var skipUntilPTS: CMTime? + var feedEpoch: UInt64 = 0 + func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws {} + func open(parameterSets: [[UInt8]], timeBase: AVRational, + onFrame: @escaping DecodedFrameHandler) throws {} + func decode(packet: UnsafeMutablePointer, epoch: UInt64?) {} + func flush() { lock.lock(); _flushes += 1; lock.unlock() } + func close() {} + } - private final class Deliveries: @unchecked Sendable { - struct Frame { let buffer: CVPixelBuffer; let pts: CMTime; let afterMarker: Bool } - private let lock = NSLock() - private var frames: [Frame] = [] - private var marker = false - func record(_ buffer: CVPixelBuffer, _ pts: CMTime) { - lock.lock(); frames.append(Frame(buffer: buffer, pts: pts, afterMarker: marker)); lock.unlock() + private final class Deliveries: @unchecked Sendable { + struct Frame { let buffer: CVPixelBuffer; let pts: CMTime; let afterMarker: Bool } + private let lock = NSLock() + private var frames: [Frame] = [] + private var marker = false + func record(_ buffer: CVPixelBuffer, _ pts: CMTime) { + lock.lock(); frames.append(Frame(buffer: buffer, pts: pts, afterMarker: marker)); lock.unlock() + } + func setMarker() { lock.lock(); marker = true; lock.unlock() } + var afterMarker: [Frame] { lock.lock(); defer { lock.unlock() }; return frames.filter(\.afterMarker) } + var count: Int { lock.lock(); defer { lock.unlock() }; return frames.count } } - func setMarker() { lock.lock(); marker = true; lock.unlock() } - var afterMarker: [Frame] { lock.lock(); defer { lock.unlock() }; return frames.filter(\.afterMarker) } - var count: Int { lock.lock(); defer { lock.unlock() }; return frames.count } - } - private struct Rig { - let decoder: DualLayerVideoDecoder - let composer: FakeComposer - let primary: FakeSession - let enhancement: FakeSession - let deliveries: Deliveries - } + private struct Rig { + let decoder: DualLayerVideoDecoder + let composer: FakeComposer + let primary: FakeSession + let enhancement: FakeSession + let deliveries: Deliveries + } - private static func rig(holdFirst: Bool = false) -> Rig { - let composer = FakeComposer(holdFirst: holdFirst) - let primary = FakeSession(), enhancement = FakeSession() - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: composer, primaryDecoder: primary, - enhancementDecoder: enhancement) - decoder.onFrame = { buffer, pts, _ in deliveries.record(buffer, pts) } - decoder.primeForTesting(enhancementLayer: true) - return Rig(decoder: decoder, composer: composer, primary: primary, enhancement: enhancement, - deliveries: deliveries) - } + private static func rig(holdFirst: Bool = false) -> Rig { + let composer = FakeComposer(holdFirst: holdFirst) + let primary = FakeSession(), enhancement = FakeSession() + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: composer, primaryDecoder: primary, + enhancementDecoder: enhancement) + decoder.onFrame = { buffer, pts, _ in deliveries.record(buffer, pts) } + decoder.primeForTesting(enhancementLayer: true) + return Rig(decoder: decoder, composer: composer, primary: primary, enhancement: enhancement, + deliveries: deliveries) + } - // MARK: - Tests + // MARK: - Tests - @Test("a seek flushes both VideoToolbox sessions and the composer, and empties the pairing state") - func flushReachesEveryStage() throws { - let r = Self.rig() - defer { r.decoder.close() } + @Test("a seek flushes both VideoToolbox sessions and the composer, and empties the pairing state") + func flushReachesEveryStage() throws { + let r = Self.rig() + defer { r.decoder.close() } - // Two complete pairs, a lone EL and a lone BL: every queue and the side-data ring hold something. - for i in 0..<4 { - r.decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nil) - } - for i in 0..<2 { - let bl = try #require(Self.buffer(width: 64, height: 64)) - let el = try #require(Self.buffer(width: 32, height: 32)) - r.decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) - r.decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + // Two complete pairs, a lone EL and a lone BL: every queue and the side-data ring hold something. + for i in 0..<4 { + r.decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nil) + } + for i in 0..<2 { + let bl = try #require(Self.buffer(width: 64, height: 64)) + let el = try #require(Self.buffer(width: 32, height: 32)) + r.decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) + r.decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + } + r.decoder.handleEnhancementLayerFrame(elBuffer: try #require(Self.buffer(width: 32, height: 32)), pts: Self.pts(2)) + r.decoder.handleBaseLayerFrame(baseBuffer: try #require(Self.buffer(width: 64, height: 64)), + pts: Self.pts(3), hdr10Data: nil) + let before = r.decoder.stateSnapshotForTesting + #expect(before.rpuCacheCount > 0) + #expect(before.elQueueCount + before.blQueueCount > 0) + + let epoch = r.decoder.feedEpoch + r.decoder.flush() + + #expect(r.primary.flushCount == 1) + #expect(r.enhancement.flushCount == 1) + #expect(r.composer.flushCount == 1) + #expect(r.decoder.feedEpoch == epoch + 1) + let after = r.decoder.stateSnapshotForTesting + #expect(after.blQueueCount == 0 && after.elQueueCount == 0) + #expect(after.rpuCacheCount == 0 && after.dmRpuCacheCount == 0) + #expect(!after.draining) } - r.decoder.handleEnhancementLayerFrame(elBuffer: try #require(Self.buffer(width: 32, height: 32)), pts: Self.pts(2)) - r.decoder.handleBaseLayerFrame(baseBuffer: try #require(Self.buffer(width: 64, height: 64)), - pts: Self.pts(3), hdr10Data: nil) - let before = r.decoder.stateSnapshotForTesting - #expect(before.rpuCacheCount > 0) - #expect(before.elQueueCount + before.blQueueCount > 0) - - let epoch = r.decoder.feedEpoch - r.decoder.flush() - - #expect(r.primary.flushCount == 1) - #expect(r.enhancement.flushCount == 1) - #expect(r.composer.flushCount == 1) - #expect(r.decoder.feedEpoch == epoch + 1) - let after = r.decoder.stateSnapshotForTesting - #expect(after.blQueueCount == 0 && after.elQueueCount == 0) - #expect(after.rpuCacheCount == 0 && after.dmRpuCacheCount == 0) - #expect(!after.draining) - } - @Test("an enhancement layer from before a seek is never composed onto a post-seek base layer") - func staleEnhancementLayerIsNotPaired() async throws { - let r = Self.rig() - defer { r.decoder.close() } - - // The pre-seek EL for frame 10 is decoded, its base layer never is: the seek retires it. - let staleEL = try #require(Self.buffer(width: 32, height: 32)) - r.decoder.handleEnhancementLayerFrame(elBuffer: staleEL, pts: Self.pts(10)) - r.decoder.flush() - r.deliveries.setMarker() - - // A backward seek replays the same timestamps. The base layer comes first this time. - let postBL = try #require(Self.buffer(width: 64, height: 64)) - let postEL = try #require(Self.buffer(width: 32, height: 32)) - r.decoder.seedRPUForTesting(pts: Self.pts(10), params: Self.params(), displayMetadataNAL: nil) - r.decoder.handleBaseLayerFrame(baseBuffer: postBL, pts: Self.pts(10), hdr10Data: nil) - r.decoder.quiesceForTesting() - #expect(r.composer.pairs.isEmpty, "the post-seek base layer was composed with a pre-seek enhancement layer") - - r.decoder.handleEnhancementLayerFrame(elBuffer: postEL, pts: Self.pts(10)) - try await waitFor { r.deliveries.afterMarker.count == 1 } - let pairs = r.composer.pairs - #expect(pairs.count == 1) - #expect(pairs.first?.base === postBL) - #expect(pairs.first?.enhancement === postEL) - #expect(pairs.allSatisfy { $0.enhancement !== staleEL }) - } + @Test("an enhancement layer from before a seek is never composed onto a post-seek base layer") + func staleEnhancementLayerIsNotPaired() async throws { + let r = Self.rig() + defer { r.decoder.close() } + + // The pre-seek EL for frame 10 is decoded, its base layer never is: the seek retires it. + let staleEL = try #require(Self.buffer(width: 32, height: 32)) + r.decoder.handleEnhancementLayerFrame(elBuffer: staleEL, pts: Self.pts(10)) + r.decoder.flush() + r.deliveries.setMarker() + + // A backward seek replays the same timestamps. The base layer comes first this time. + let postBL = try #require(Self.buffer(width: 64, height: 64)) + let postEL = try #require(Self.buffer(width: 32, height: 32)) + r.decoder.seedRPUForTesting(pts: Self.pts(10), params: Self.params(), displayMetadataNAL: nil) + r.decoder.handleBaseLayerFrame(baseBuffer: postBL, pts: Self.pts(10), hdr10Data: nil) + r.decoder.quiesceForTesting() + #expect(r.composer.pairs.isEmpty, "the post-seek base layer was composed with a pre-seek enhancement layer") + + r.decoder.handleEnhancementLayerFrame(elBuffer: postEL, pts: Self.pts(10)) + try await waitFor { r.deliveries.afterMarker.count == 1 } + let pairs = r.composer.pairs + #expect(pairs.count == 1) + #expect(pairs.first?.base === postBL) + #expect(pairs.first?.enhancement === postEL) + #expect(pairs.allSatisfy { $0.enhancement !== staleEL }) + } - @Test("a base layer waiting for its enhancement layer when the seek lands is dropped, not delivered late") - func staleBaseLayerIsNotDelivered() async throws { - let r = Self.rig() - defer { r.decoder.close() } - - let staleBL = try #require(Self.buffer(width: 64, height: 64)) - r.decoder.seedRPUForTesting(pts: Self.pts(5), params: Self.params(), displayMetadataNAL: nil) - r.decoder.handleBaseLayerFrame(baseBuffer: staleBL, pts: Self.pts(5), hdr10Data: nil) - r.decoder.flush() - r.deliveries.setMarker() - - // The post-seek EL for the same key must not find the pre-seek base layer. - let postEL = try #require(Self.buffer(width: 32, height: 32)) - r.decoder.handleEnhancementLayerFrame(elBuffer: postEL, pts: Self.pts(5)) - r.decoder.quiesceForTesting() - #expect(r.composer.pairs.isEmpty) - #expect(r.deliveries.afterMarker.isEmpty) - - let postBL = try #require(Self.buffer(width: 64, height: 64)) - r.decoder.seedRPUForTesting(pts: Self.pts(5), params: Self.params(), displayMetadataNAL: nil) - r.decoder.handleBaseLayerFrame(baseBuffer: postBL, pts: Self.pts(5), hdr10Data: nil) - try await waitFor { r.deliveries.afterMarker.count == 1 } - #expect(r.deliveries.afterMarker.first?.buffer === postBL) - #expect(r.composer.pairs.first?.base === postBL) - #expect(r.composer.pairs.first?.enhancement === postEL) - } + @Test("a base layer waiting for its enhancement layer when the seek lands is dropped, not delivered late") + func staleBaseLayerIsNotDelivered() async throws { + let r = Self.rig() + defer { r.decoder.close() } + + let staleBL = try #require(Self.buffer(width: 64, height: 64)) + r.decoder.seedRPUForTesting(pts: Self.pts(5), params: Self.params(), displayMetadataNAL: nil) + r.decoder.handleBaseLayerFrame(baseBuffer: staleBL, pts: Self.pts(5), hdr10Data: nil) + r.decoder.flush() + r.deliveries.setMarker() + + // The post-seek EL for the same key must not find the pre-seek base layer. + let postEL = try #require(Self.buffer(width: 32, height: 32)) + r.decoder.handleEnhancementLayerFrame(elBuffer: postEL, pts: Self.pts(5)) + r.decoder.quiesceForTesting() + #expect(r.composer.pairs.isEmpty) + #expect(r.deliveries.afterMarker.isEmpty) + + let postBL = try #require(Self.buffer(width: 64, height: 64)) + r.decoder.seedRPUForTesting(pts: Self.pts(5), params: Self.params(), displayMetadataNAL: nil) + r.decoder.handleBaseLayerFrame(baseBuffer: postBL, pts: Self.pts(5), hdr10Data: nil) + try await waitFor { r.deliveries.afterMarker.count == 1 } + #expect(r.deliveries.afterMarker.first?.buffer === postBL) + #expect(r.composer.pairs.first?.base === postBL) + #expect(r.composer.pairs.first?.enhancement === postEL) + } - @Test("after a seek every frame is composed with its own layer's picture, in either arrival order") - func postSeekFramesPairByPicture() async throws { - let r = Self.rig() - defer { r.decoder.close() } + @Test("after a seek every frame is composed with its own layer's picture, in either arrival order") + func postSeekFramesPairByPicture() async throws { + let r = Self.rig() + defer { r.decoder.close() } - // Pre-seek leftovers on keys the replay is about to reuse. - for i in 10..<13 { - r.decoder.handleEnhancementLayerFrame(elBuffer: try #require(Self.buffer(width: 32, height: 32)), pts: Self.pts(Int64(i))) - } - r.decoder.handleBaseLayerFrame(baseBuffer: try #require(Self.buffer(width: 64, height: 64)), pts: Self.pts(13), hdr10Data: nil) - r.decoder.flush() - r.deliveries.setMarker() - - var bases: [CVPixelBuffer] = [], enhancements: [CVPixelBuffer] = [] - for i in 0..<6 { - let bl = try #require(Self.buffer(width: 64, height: 64)) - let el = try #require(Self.buffer(width: 32, height: 32)) - bases.append(bl); enhancements.append(el) - let key = Int64(10 + i) - r.decoder.seedRPUForTesting(pts: Self.pts(key), params: Self.params(), displayMetadataNAL: nil) - if i % 2 == 0 { - r.decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(key), hdr10Data: nil) - r.decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(key)) - } else { - r.decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(key)) - r.decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(key), hdr10Data: nil) + // Pre-seek leftovers on keys the replay is about to reuse. + for i in 10..<13 { + r.decoder.handleEnhancementLayerFrame(elBuffer: try #require(Self.buffer(width: 32, height: 32)), pts: Self.pts(Int64(i))) + } + r.decoder.handleBaseLayerFrame(baseBuffer: try #require(Self.buffer(width: 64, height: 64)), pts: Self.pts(13), hdr10Data: nil) + r.decoder.flush() + r.deliveries.setMarker() + + var bases: [CVPixelBuffer] = [], enhancements: [CVPixelBuffer] = [] + for i in 0..<6 { + let bl = try #require(Self.buffer(width: 64, height: 64)) + let el = try #require(Self.buffer(width: 32, height: 32)) + bases.append(bl); enhancements.append(el) + let key = Int64(10 + i) + r.decoder.seedRPUForTesting(pts: Self.pts(key), params: Self.params(), displayMetadataNAL: nil) + if i % 2 == 0 { + r.decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(key), hdr10Data: nil) + r.decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(key)) + } else { + r.decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(key)) + r.decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(key), hdr10Data: nil) + } + } + try await waitFor { r.deliveries.afterMarker.count == 6 } + let delivered = r.deliveries.afterMarker + #expect(zip(delivered, delivered.dropFirst()).allSatisfy { CMTimeCompare($0.pts, $1.pts) < 0 }) + #expect(zip(delivered, bases).allSatisfy { $0.buffer === $1 }) + let pairs = r.composer.pairs + #expect(pairs.count == 6) + for pair in pairs { + let i = bases.firstIndex { $0 === pair.base } + #expect(i != nil && pair.enhancement === enhancements[i!], "base and enhancement are not the same picture") } } - try await waitFor { r.deliveries.afterMarker.count == 6 } - let delivered = r.deliveries.afterMarker - #expect(zip(delivered, delivered.dropFirst()).allSatisfy { CMTimeCompare($0.pts, $1.pts) < 0 }) - #expect(zip(delivered, bases).allSatisfy { $0.buffer === $1 }) - let pairs = r.composer.pairs - #expect(pairs.count == 6) - for pair in pairs { - let i = bases.firstIndex { $0 === pair.base } - #expect(i != nil && pair.enhancement === enhancements[i!], "base and enhancement are not the same picture") - } - } - @Test("a seek during an in-flight composition drops that frame, and the first post-seek frame is the paired one") - func seekDuringInFlightCompose() async throws { - let r = Self.rig(holdFirst: true) - defer { r.decoder.close() } - - let staleBL = try #require(Self.buffer(width: 64, height: 64)) - let staleEL = try #require(Self.buffer(width: 32, height: 32)) - r.decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - r.decoder.handleEnhancementLayerFrame(elBuffer: staleEL, pts: Self.pts(1)) - r.decoder.handleBaseLayerFrame(baseBuffer: staleBL, pts: Self.pts(1), hdr10Data: nil) - #expect(r.composer.pairs.count == 1, "the first composition must be on the GPU") - - // The seek lands while it is there. - r.decoder.flush() - r.deliveries.setMarker() - - let postBL = try #require(Self.buffer(width: 64, height: 64)) - let postEL = try #require(Self.buffer(width: 32, height: 32)) - r.decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - r.decoder.handleEnhancementLayerFrame(elBuffer: postEL, pts: Self.pts(1)) - r.decoder.handleBaseLayerFrame(baseBuffer: postBL, pts: Self.pts(1), hdr10Data: nil) - try await waitFor { r.deliveries.afterMarker.count == 1 } - - // The pre-seek composition retires last, after the post-seek frame is already out. - r.composer.releaseHeld() - r.decoder.quiesceForTesting() - - let late = r.deliveries.afterMarker - #expect(late.count == 1) - #expect(late.first?.buffer === postBL) - #expect(late.allSatisfy { $0.buffer !== staleBL }) - let pairs = r.composer.pairs - #expect(pairs.count == 2) - #expect(pairs.last?.base === postBL && pairs.last?.enhancement === postEL) + @Test("a seek during an in-flight composition drops that frame, and the first post-seek frame is the paired one") + func seekDuringInFlightCompose() async throws { + let r = Self.rig(holdFirst: true) + defer { r.decoder.close() } + + let staleBL = try #require(Self.buffer(width: 64, height: 64)) + let staleEL = try #require(Self.buffer(width: 32, height: 32)) + r.decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) + r.decoder.handleEnhancementLayerFrame(elBuffer: staleEL, pts: Self.pts(1)) + r.decoder.handleBaseLayerFrame(baseBuffer: staleBL, pts: Self.pts(1), hdr10Data: nil) + #expect(r.composer.pairs.count == 1, "the first composition must be on the GPU") + + // The seek lands while it is there. + r.decoder.flush() + r.deliveries.setMarker() + + let postBL = try #require(Self.buffer(width: 64, height: 64)) + let postEL = try #require(Self.buffer(width: 32, height: 32)) + r.decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) + r.decoder.handleEnhancementLayerFrame(elBuffer: postEL, pts: Self.pts(1)) + r.decoder.handleBaseLayerFrame(baseBuffer: postBL, pts: Self.pts(1), hdr10Data: nil) + try await waitFor { r.deliveries.afterMarker.count == 1 } + + // The pre-seek composition retires last, after the post-seek frame is already out. + r.composer.releaseHeld() + r.decoder.quiesceForTesting() + + let late = r.deliveries.afterMarker + #expect(late.count == 1) + #expect(late.first?.buffer === postBL) + #expect(late.allSatisfy { $0.buffer !== staleBL }) + let pairs = r.composer.pairs + #expect(pairs.count == 2) + #expect(pairs.last?.base === postBL && pairs.last?.enhancement === postEL) + } } } #endif diff --git a/Tests/AetherEngineTests/DualLayerStreamClassificationTests.swift b/Tests/AetherEngineTests/DualLayerStreamClassificationTests.swift index fd9109849..e8d011023 100644 --- a/Tests/AetherEngineTests/DualLayerStreamClassificationTests.swift +++ b/Tests/AetherEngineTests/DualLayerStreamClassificationTests.swift @@ -8,236 +8,238 @@ import Metal import AetherLibavcodec @testable import AetherEngine -/// Stream classification is decided once, from the first RPU's `el_type`, before -/// the enhancement decoder opens. A per-frame `disable_residual_flag` is a kernel flag -/// (`DoviComposerFlags.disableResidual`, honoured by the composer) and must never shut -/// off the enhancement layer for the rest of the session. -@Suite(.serialized, .offCooperativePoolOneLane, .timeLimit(.minutes(5))) -struct DualLayerStreamClassificationTests { +extension HeavyDualLayerSuites { + /// Stream classification is decided once, from the first RPU's `el_type`, before + /// the enhancement decoder opens. A per-frame `disable_residual_flag` is a kernel flag + /// (`DoviComposerFlags.disableResidual`, honoured by the composer) and must never shut + /// off the enhancement layer for the rest of the session. + @Suite(.serialized, .offCooperativePool, .timeLimit(.minutes(5))) + struct DualLayerStreamClassificationTests { - // MARK: - Fixtures + // MARK: - Fixtures - private static let frameTicks: Int64 = 3754 + private static let frameTicks: Int64 = 3754 - private static func pts(_ frame: Int64) -> CMTime { - CMTime(value: frame * frameTicks, timescale: 90000) - } - - private static var metalAvailable: Bool { MTLCreateSystemDefaultDevice() != nil } + private static func pts(_ frame: Int64) -> CMTime { + CMTime(value: frame * frameTicks, timescale: 90000) + } - private static func hexToBytes(_ hex: String) -> [UInt8] { - var bytes: [UInt8] = [] - var i = hex.startIndex - while i < hex.endIndex { - let next = hex.index(i, offsetBy: 2) - if let byte = UInt8(hex[i.. [UInt8] { + var bytes: [UInt8] = [] + var i = hex.startIndex + while i < hex.endIndex { + let next = hex.index(i, offsetBy: 2) + if let byte = UInt8(hex[i.. [UInt8] { - [UInt8(type << 1), 0x01] + payload - } + /// 2-byte HEVC NAL header + payload. + private func hevcNAL(type: UInt8, payload: [UInt8]) -> [UInt8] { + [UInt8(type << 1), 0x01] + payload + } - /// Pack complete NALs into an AVCC (4-byte BE length prefix) AVPacket. - private func avccPacket(_ nals: [[UInt8]]) -> UnsafeMutablePointer { - var bytes: [UInt8] = [] - for nal in nals { - let n = nal.count - bytes.append(UInt8((n >> 24) & 0xFF)) - bytes.append(UInt8((n >> 16) & 0xFF)) - bytes.append(UInt8((n >> 8) & 0xFF)) - bytes.append(UInt8(n & 0xFF)) - bytes.append(contentsOf: nal) - } - let pkt = av_packet_alloc()! - _ = av_new_packet(pkt, Int32(bytes.count)) - bytes.withUnsafeBytes { src in - _ = memcpy(pkt.pointee.data, src.baseAddress, bytes.count) - } - return pkt - } + /// Pack complete NALs into an AVCC (4-byte BE length prefix) AVPacket. + private func avccPacket(_ nals: [[UInt8]]) -> UnsafeMutablePointer { + var bytes: [UInt8] = [] + for nal in nals { + let n = nal.count + bytes.append(UInt8((n >> 24) & 0xFF)) + bytes.append(UInt8((n >> 16) & 0xFF)) + bytes.append(UInt8((n >> 8) & 0xFF)) + bytes.append(UInt8(n & 0xFF)) + bytes.append(contentsOf: nal) + } + let pkt = av_packet_alloc()! + _ = av_new_packet(pkt, Int32(bytes.count)) + bytes.withUnsafeBytes { src in + _ = memcpy(pkt.pointee.data, src.baseAddress, bytes.count) + } + return pkt + } - private func free(_ pkt: UnsafeMutablePointer) { - var p: UnsafeMutablePointer? = pkt - av_packet_free(&p) - } + private func free(_ pkt: UnsafeMutablePointer) { + var p: UnsafeMutablePointer? = pkt + av_packet_free(&p) + } - private func packetResult( - isFEL: Bool, - isMEL: Bool = false, - params: DoviMetalParams? = nil - ) -> DoviRpuConverter.DualLayerPacketResult { - DoviRpuConverter.DualLayerPacketResult( - elPacket: nil, rpuData: nil, rpuParams: params, - isFEL: isFEL, isMEL: isMEL, success: true - ) - } + private func packetResult( + isFEL: Bool, + isMEL: Bool = false, + params: DoviMetalParams? = nil + ) -> DoviRpuConverter.DualLayerPacketResult { + DoviRpuConverter.DualLayerPacketResult( + elPacket: nil, rpuData: nil, rpuParams: params, + isFEL: isFEL, isMEL: isMEL, success: true + ) + } - private static func makeBuffer(width: Int, height: Int) -> CVPixelBuffer? { - var out: CVPixelBuffer? - let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, - kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, - [kCVPixelBufferMetalCompatibilityKey: true, - kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary] as CFDictionary, - &out) - guard status == kCVReturnSuccess, let buf = out else { return nil } - return buf - } + private static func makeBuffer(width: Int, height: Int) -> CVPixelBuffer? { + var out: CVPixelBuffer? + let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, + kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, + [kCVPixelBufferMetalCompatibilityKey: true, + kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary] as CFDictionary, + &out) + guard status == kCVReturnSuccess, let buf = out else { return nil } + return buf + } - private static func baseLayer() -> CVPixelBuffer? { makeBuffer(width: 256, height: 256) } - private static func enhancementLayer() -> CVPixelBuffer? { makeBuffer(width: 128, height: 128) } + private static func baseLayer() -> CVPixelBuffer? { makeBuffer(width: 256, height: 256) } + private static func enhancementLayer() -> CVPixelBuffer? { makeBuffer(width: 128, height: 128) } - private static func params() -> DoviMetalParams { - var p = DoviMetalParams() - p.blWidth = 256; p.blHeight = 256; p.elWidth = 128; p.elHeight = 128 - return p - } + private static func params() -> DoviMetalParams { + var p = DoviMetalParams() + p.blWidth = 256; p.blHeight = 256; p.elWidth = 128; p.elHeight = 128 + return p + } - private final class Deliveries: @unchecked Sendable { - private let lock = NSLock() - private var buffers: [(CVPixelBuffer, CMTime)] = [] + private final class Deliveries: @unchecked Sendable { + private let lock = NSLock() + private var buffers: [(CVPixelBuffer, CMTime)] = [] - func record(_ buffer: CVPixelBuffer, _ pts: CMTime, _ hdr10: Data?) { - lock.lock() - buffers.append((buffer, pts)) - lock.unlock() - } + func record(_ buffer: CVPixelBuffer, _ pts: CMTime, _ hdr10: Data?) { + lock.lock() + buffers.append((buffer, pts)) + lock.unlock() + } - var snapshot: [(CVPixelBuffer, CMTime)] { - lock.lock(); defer { lock.unlock() }; return buffers + var snapshot: [(CVPixelBuffer, CMTime)] { + lock.lock(); defer { lock.unlock() }; return buffers + } + var count: Int { lock.lock(); defer { lock.unlock() }; return buffers.count } } - var count: Int { lock.lock(); defer { lock.unlock() }; return buffers.count } - } - // MARK: - classifyStream unit tests + // MARK: - classifyStream unit tests - @Test("classifyStream maps a FEL packet result to FEL") - func classifyFEL() { - #expect(DoviRpuConverter.classifyStream(packetResult(isFEL: true)) == .fel) - } + @Test("classifyStream maps a FEL packet result to FEL") + func classifyFEL() { + #expect(DoviRpuConverter.classifyStream(packetResult(isFEL: true)) == .fel) + } - @Test("classifyStream maps a MEL packet result to MEL") - func classifyMEL() { - #expect(DoviRpuConverter.classifyStream(packetResult(isFEL: false, isMEL: true)) == .mel) - } + @Test("classifyStream maps a MEL packet result to MEL") + func classifyMEL() { + #expect(DoviRpuConverter.classifyStream(packetResult(isFEL: false, isMEL: true)) == .mel) + } - @Test("classifyStream maps a packet with no RPU signal to undetermined") - func classifyUndetermined() { - #expect(DoviRpuConverter.classifyStream(packetResult(isFEL: false)) == .undetermined) - } + @Test("classifyStream maps a packet with no RPU signal to undetermined") + func classifyUndetermined() { + #expect(DoviRpuConverter.classifyStream(packetResult(isFEL: false)) == .undetermined) + } - @Test("classifyStream ignores the per-frame disable_residual kernel flag") - func classifyIgnoresDisableResidualFlag() { - var p = Self.params() - p.flags.insert(.disableResidual) - // This is what `separateDualLayers` now produces for a FEL RPU with - // `disable_residual_flag` set: FEL reporting plus the kernel flag. - let result = packetResult(isFEL: true, params: p) - #expect(DoviRpuConverter.classifyStream(result) == .fel) - } + @Test("classifyStream ignores the per-frame disable_residual kernel flag") + func classifyIgnoresDisableResidualFlag() { + var p = Self.params() + p.flags.insert(.disableResidual) + // This is what `separateDualLayers` now produces for a FEL RPU with + // `disable_residual_flag` set: FEL reporting plus the kernel flag. + let result = packetResult(isFEL: true, params: p) + #expect(DoviRpuConverter.classifyStream(result) == .fel) + } - // MARK: - Converter integration over a real RPU + // MARK: - Converter integration over a real RPU - @Test("a real FEL RPU classifies FEL, not MEL") - func realFELRPUClassifiesFEL() { - // Raw unspec62 NAL (header included) from a FEL title; shared with DoviFelMathTests. - let rpu = Self.hexToBytes(DoviFelMathTests.clipARpuHex) - let pkt = avccPacket([hevcNAL(type: 1, payload: [0xAA, 0xBB]), rpu]) - defer { free(pkt) } + @Test("a real FEL RPU classifies FEL, not MEL") + func realFELRPUClassifiesFEL() { + // Raw unspec62 NAL (header included) from a FEL title; shared with DoviFelMathTests. + let rpu = Self.hexToBytes(DoviFelMathTests.clipARpuHex) + let pkt = avccPacket([hevcNAL(type: 1, payload: [0xAA, 0xBB]), rpu]) + defer { free(pkt) } - let result = DoviRpuConverter.separateDualLayers(pkt) - #expect(result.isFEL) - #expect(!result.isMEL) - #expect(DoviRpuConverter.classifyStream(result) == .fel) - #expect(result.rpuParams != nil) - } + let result = DoviRpuConverter.separateDualLayers(pkt) + #expect(result.isFEL) + #expect(!result.isMEL) + #expect(DoviRpuConverter.classifyStream(result) == .fel) + #expect(result.rpuParams != nil) + } - // MARK: - Decoder one-shot latch + // MARK: - Decoder one-shot latch - @Test("a FEL stream stays FEL across a disable_residual RPU and later reports") - func felStaysFELAcrossDisableResidualFrame() { - let decoder = DualLayerVideoDecoder() - defer { decoder.close() } - #expect(decoder.streamClassificationForTesting == .undetermined) + @Test("a FEL stream stays FEL across a disable_residual RPU and later reports") + func felStaysFELAcrossDisableResidualFrame() { + let decoder = DualLayerVideoDecoder() + defer { decoder.close() } + #expect(decoder.streamClassificationForTesting == .undetermined) - var residualParams = Self.params() - residualParams.flags.insert(.disableResidual) + var residualParams = Self.params() + residualParams.flags.insert(.disableResidual) - decoder.classifyPacketResultForTesting(packetResult(isFEL: true)) - #expect(decoder.streamClassificationForTesting == .fel) - #expect(decoder.engagesEnhancementLayerForTesting()) + decoder.classifyPacketResultForTesting(packetResult(isFEL: true)) + #expect(decoder.streamClassificationForTesting == .fel) + #expect(decoder.engagesEnhancementLayerForTesting()) - // One RPU with the residual switched off: kernel flag only, the EL keeps decoding. - decoder.classifyPacketResultForTesting(packetResult(isFEL: true, params: residualParams)) - #expect(decoder.streamClassificationForTesting == .fel) - #expect(decoder.engagesEnhancementLayerForTesting()) + // One RPU with the residual switched off: kernel flag only, the EL keeps decoding. + decoder.classifyPacketResultForTesting(packetResult(isFEL: true, params: residualParams)) + #expect(decoder.streamClassificationForTesting == .fel) + #expect(decoder.engagesEnhancementLayerForTesting()) - // A later MEL report cannot flip the one-shot latch either. - decoder.classifyPacketResultForTesting(packetResult(isFEL: false, isMEL: true)) - #expect(decoder.streamClassificationForTesting == .fel) - #expect(decoder.engagesEnhancementLayerForTesting()) - } + // A later MEL report cannot flip the one-shot latch either. + decoder.classifyPacketResultForTesting(packetResult(isFEL: false, isMEL: true)) + #expect(decoder.streamClassificationForTesting == .fel) + #expect(decoder.engagesEnhancementLayerForTesting()) + } - @Test("the first RPU decides: MEL never engages the enhancement layer") - func melNeverEngagesEnhancementLayer() { - let decoder = DualLayerVideoDecoder() - defer { decoder.close() } + @Test("the first RPU decides: MEL never engages the enhancement layer") + func melNeverEngagesEnhancementLayer() { + let decoder = DualLayerVideoDecoder() + defer { decoder.close() } - decoder.classifyPacketResultForTesting(packetResult(isFEL: false, isMEL: true)) - #expect(decoder.streamClassificationForTesting == .mel) - #expect(!decoder.engagesEnhancementLayerForTesting()) + decoder.classifyPacketResultForTesting(packetResult(isFEL: false, isMEL: true)) + #expect(decoder.streamClassificationForTesting == .mel) + #expect(!decoder.engagesEnhancementLayerForTesting()) - // A later FEL report cannot reopen what MEL never opened. - decoder.classifyPacketResultForTesting(packetResult(isFEL: true)) - #expect(decoder.streamClassificationForTesting == .mel) - #expect(!decoder.engagesEnhancementLayerForTesting()) - } + // A later FEL report cannot reopen what MEL never opened. + decoder.classifyPacketResultForTesting(packetResult(isFEL: true)) + #expect(decoder.streamClassificationForTesting == .mel) + #expect(!decoder.engagesEnhancementLayerForTesting()) + } - @Test("a MEL-primed decoder delivers the base layer directly") - func melBypassesToBaseLayer() async throws { - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder() - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeStreamForTesting(.mel) - defer { decoder.close() } - - let bl = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(0), hdr10Data: nil) - - // A bypass is a `fulfil` onto `OrderedDelivery`'s queue - // (`queue.async`), not a synchronous call to `onFrame` on the calling - // thread, so `handleBaseLayerFrame` returning is no longer proof the - // frame has been delivered. - try await waitFor { deliveries.count == 1 } - let got = deliveries.snapshot - #expect(got.count == 1) - #expect(got.first?.0 === bl) - } + @Test("a MEL-primed decoder delivers the base layer directly") + func melBypassesToBaseLayer() async throws { + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder() + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeStreamForTesting(.mel) + defer { decoder.close() } + + let bl = try #require(Self.baseLayer()) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(0), hdr10Data: nil) + + // A bypass is a `fulfil` onto `OrderedDelivery`'s queue + // (`queue.async`), not a synchronous call to `onFrame` on the calling + // thread, so `handleBaseLayerFrame` returning is no longer proof the + // frame has been delivered. + try await waitFor { deliveries.count == 1 } + let got = deliveries.snapshot + #expect(got.count == 1) + #expect(got.first?.0 === bl) + } - @Test("a frame whose RPU disables the residual still composes with its enhancement layer") - func disableResidualFrameStillComposes() async throws { - guard Self.metalAvailable else { return } - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder() - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeStreamForTesting(.fel) - defer { decoder.close() } - - var p = Self.params() - p.flags.insert(.disableResidual) - decoder.seedRPUForTesting(pts: Self.pts(1), params: p, displayMetadataNAL: nil) - - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) - - try await waitFor { deliveries.count == 1 } - let got = try #require(deliveries.snapshot.first) - #expect(got.0 !== bl, "a disable_residual RPU must not bypass composition") + @Test("a frame whose RPU disables the residual still composes with its enhancement layer") + func disableResidualFrameStillComposes() async throws { + guard Self.metalAvailable else { return } + let bl = try #require(Self.baseLayer()) + let el = try #require(Self.enhancementLayer()) + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder() + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeStreamForTesting(.fel) + defer { decoder.close() } + + var p = Self.params() + p.flags.insert(.disableResidual) + decoder.seedRPUForTesting(pts: Self.pts(1), params: p, displayMetadataNAL: nil) + + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) + + try await waitFor { deliveries.count == 1 } + let got = try #require(deliveries.snapshot.first) + #expect(decoder.composedFrameCount == 1, "a disable_residual RPU must not bypass composition") + } } } #endif diff --git a/Tests/AetherEngineTests/DualLayerVideoDecoderTests.swift b/Tests/AetherEngineTests/DualLayerVideoDecoderTests.swift index 612c0bf8b..b51a18263 100644 --- a/Tests/AetherEngineTests/DualLayerVideoDecoderTests.swift +++ b/Tests/AetherEngineTests/DualLayerVideoDecoderTests.swift @@ -11,2748 +11,2853 @@ import AetherLibavutil import Dovi @_spi(Diagnostics) @testable import AetherEngine -/// `DualLayerVideoDecoder` pairs a 4K base-layer frame with its 1080p enhancement-layer frame and -/// the RPU cached for that PTS, composes them on the GPU, and delivers the result in decode order. -/// Both layers arrive on VideoToolbox callback threads, in either order, and compositions retire -/// asynchronously. These tests drive the two frame handlers directly with synthetic P010 buffers, -/// using `primeForTesting` / `seedRPUForTesting` to stand in for what `decode(packet:)` sets up, so -/// the pairing, ordering, metadata and flush behaviour is covered without a real stream. -@Suite(.serialized, .offCooperativePoolOneLane, .timeLimit(.minutes(5))) -struct DualLayerVideoDecoderTests { - - // MARK: - Fixtures - - /// 23.976 fps at 90 kHz, rounded; only monotonic, well-separated keys matter here. - private static let frameTicks: Int64 = 3754 - - private static func pts(_ frame: Int64, offsetTicks: Int64 = 0) -> CMTime { - CMTime(value: frame * frameTicks + offsetTicks, timescale: 90000) - } - - private static var metalAvailable: Bool { MTLCreateSystemDefaultDevice() != nil } - - private static func makeBuffer(width: Int, height: Int, fill: UInt16) -> CVPixelBuffer? { - var out: CVPixelBuffer? - let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, - kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, - [kCVPixelBufferMetalCompatibilityKey: true, - kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary] as CFDictionary, - &out) - guard status == kCVReturnSuccess, let buf = out else { return nil } - CVPixelBufferLockBaseAddress(buf, []) - defer { CVPixelBufferUnlockBaseAddress(buf, []) } - for plane in 0.. CVPixelBuffer? { makeBuffer(width: 256, height: 256, fill: 0x8000) } - private static func enhancementLayer() -> CVPixelBuffer? { makeBuffer(width: 128, height: 128, fill: 0x8000) } - - /// Identity reshaping at the test plane sizes; the plumbing is under test, not the curve. - private static func params() -> DoviMetalParams { - var p = DoviMetalParams() - p.blWidth = 256; p.blHeight = 256; p.elWidth = 128; p.elHeight = 128 - return p - } - - /// A Profile 8.1 `unspec62` NAL shaped like `dovi_write_unspec62_nalu` output: header - /// `0x7C 0x01`, then a payload starting with the RPU prefix `0x19`, tagged so frames differ. - private static func rpuNAL(tag: UInt8) -> [UInt8] { [0x7C, 0x01, 0x19, 0x08, tag, 0xA5, 0x80] } - - /// Records what reached `onFrame`, reading the Dolby Vision attachments at delivery time, - /// which is when the display layer would read them. - private final class Deliveries: @unchecked Sendable { - struct Frame { - let buffer: CVPixelBuffer - let pts: CMTime - let hdr10: Data? - let rpuPayload: Data? - let compatibilityID: CFTypeRef? - let afterMarker: Bool +extension HeavyDualLayerSuites { + /// `DualLayerVideoDecoder` pairs a 4K base-layer frame with its 1080p enhancement-layer frame and + /// the RPU cached for that PTS, composes them on the GPU, and delivers the result in decode order. + /// Both layers arrive on VideoToolbox callback threads, in either order, and compositions retire + /// asynchronously. These tests drive the two frame handlers directly with synthetic P010 buffers, + /// using `primeForTesting` / `seedRPUForTesting` to stand in for what `decode(packet:)` sets up, so + /// the pairing, ordering, metadata and flush behaviour is covered without a real stream. + @Suite(.serialized, .offCooperativePool, .timeLimit(.minutes(5))) + struct DualLayerVideoDecoderTests { + + // MARK: - Fixtures + + /// 23.976 fps at 90 kHz, rounded; only monotonic, well-separated keys matter here. + private static let frameTicks: Int64 = 3754 + + private static func pts(_ frame: Int64, offsetTicks: Int64 = 0) -> CMTime { + CMTime(value: frame * frameTicks + offsetTicks, timescale: 90000) + } + + private static var metalAvailable: Bool { MTLCreateSystemDefaultDevice() != nil } + + private static func makeBuffer(width: Int, height: Int, fill: UInt16) -> CVPixelBuffer? { + var out: CVPixelBuffer? + let status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, + kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, + [kCVPixelBufferMetalCompatibilityKey: true, + kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary] as CFDictionary, + &out) + guard status == kCVReturnSuccess, let buf = out else { return nil } + CVPixelBufferLockBaseAddress(buf, []) + defer { CVPixelBufferUnlockBaseAddress(buf, []) } + for plane in 0.. CVPixelBuffer? { makeBuffer(width: 256, height: 256, fill: 0x8000) } + private static func enhancementLayer() -> CVPixelBuffer? { makeBuffer(width: 128, height: 128, fill: 0x8000) } - func record(_ buffer: CVPixelBuffer, _ pts: CMTime, _ hdr10: Data?) { - let payload = CVBufferCopyAttachment(buffer, DoviDisplayMetadata.rpuDataKey as CFString, nil) as? Data - let compat = CVBufferCopyAttachment(buffer, DoviDisplayMetadata.compatibilityIDKey as CFString, nil) - lock.lock() - frames.append(Frame(buffer: buffer, pts: pts, hdr10: hdr10, rpuPayload: payload, - compatibilityID: compat, afterMarker: marker)) - lock.unlock() + /// Identity reshaping at the test plane sizes; the plumbing is under test, not the curve. + private static func params() -> DoviMetalParams { + var p = DoviMetalParams() + p.blWidth = 256; p.blHeight = 256; p.elWidth = 128; p.elHeight = 128 + return p } - /// Marks the point a test's flush/close returned; frames delivered later carry the mark. - func setMarker() { lock.lock(); marker = true; lock.unlock() } + /// A Profile 8.1 `unspec62` NAL shaped like `dovi_write_unspec62_nalu` output: header + /// `0x7C 0x01`, then a payload starting with the RPU prefix `0x19`, tagged so frames differ. + private static func rpuNAL(tag: UInt8) -> [UInt8] { [0x7C, 0x01, 0x19, 0x08, tag, 0xA5, 0x80] } - var snapshot: [Frame] { lock.lock(); defer { lock.unlock() }; return frames } - var count: Int { lock.lock(); defer { lock.unlock() }; return frames.count } - } + /// Records what reached `onFrame`, reading the Dolby Vision attachments at delivery time, + /// which is when the display layer would read them. + private final class Deliveries: @unchecked Sendable { + struct Frame { + let buffer: CVPixelBuffer + let pts: CMTime + let hdr10: Data? + let rpuPayload: Data? + let compatibilityID: CFTypeRef? + let afterMarker: Bool + } - private static func decoder(recordingInto deliveries: Deliveries, enhancementLayer: Bool) -> DualLayerVideoDecoder { - let decoder = DualLayerVideoDecoder() - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: enhancementLayer) - return decoder - } + private let lock = NSLock() + private var frames: [Frame] = [] + private var marker = false + + func record(_ buffer: CVPixelBuffer, _ pts: CMTime, _ hdr10: Data?) { + let payload = CVBufferCopyAttachment(buffer, DoviDisplayMetadata.rpuDataKey as CFString, nil) as? Data + let compat = CVBufferCopyAttachment(buffer, DoviDisplayMetadata.compatibilityIDKey as CFString, nil) + lock.lock() + frames.append(Frame(buffer: buffer, pts: pts, hdr10: hdr10, rpuPayload: payload, + compatibilityID: compat, afterMarker: marker)) + lock.unlock() + } - private static func isStrictlyIncreasing(_ frames: [Deliveries.Frame]) -> Bool { - zip(frames, frames.dropFirst()).allSatisfy { CMTimeCompare($0.pts, $1.pts) < 0 } - } + /// Marks the point a test's flush/close returned; frames delivered later carry the mark. + func setMarker() { lock.lock(); marker = true; lock.unlock() } - // MARK: - Single layer + var snapshot: [Frame] { lock.lock(); defer { lock.unlock() }; return frames } + var count: Int { lock.lock(); defer { lock.unlock() }; return frames.count } + } - @Test("without an enhancement layer every base-layer frame is delivered untouched, in order") - func singleLayerBypassesInOrder() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: false) - defer { decoder.close() } - var sent: [CVPixelBuffer] = [] - for i in 0..<5 { - let bl = try #require(Self.baseLayer()) - sent.append(bl) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) - } - - // A bypass is now a `fulfil` onto `OrderedDelivery`'s queue - // (`queue.async`) rather than a synchronous call to `onFrame` on - // whichever thread paired the frame, so `handleBaseLayerFrame` - // returning is no longer a guarantee the frame has been delivered. - try await waitFor { deliveries.count == 5 } - let got = deliveries.snapshot - #expect(got.count == 5) - #expect(zip(got, sent).allSatisfy { $0.buffer === $1 }) - #expect(Self.isStrictlyIncreasing(got)) - } + private static func decoder(recordingInto deliveries: Deliveries, enhancementLayer: Bool) -> DualLayerVideoDecoder { + let decoder = DualLayerVideoDecoder() + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: enhancementLayer) + return decoder + } - // MARK: - Pairing - - @Test("base layer first, then enhancement layer: the pair is composed and delivered at the base layer's PTS", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func pairsWhenBaseLayerArrivesFirst() async throws { - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } - decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) - // Not `deliveries.count == 0`: delivery is one more `queue.async` hop - // away, so that would hold whether or not the frame is still waiting. - // `blQueueCount` is synchronous and deterministic: - // `handleBaseLayerFrame` decides queued-vs-ticketed entirely under - // `lock`, so by the time it returns, the frame is provably still - // sitting in `blFrameQueue` (not bypassed, not composed) if and only - // if it is still waiting for its enhancement layer. - #expect(decoder.stateSnapshotForTesting.blQueueCount == 1, "a base-layer frame with a pending enhancement layer must wait for it") - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) - - try await waitFor { deliveries.count == 1 } - let got = try #require(deliveries.snapshot.first) - #expect(got.buffer !== bl, "expected the composer's output, got the bypassed base layer") - #expect(CVPixelBufferGetWidth(got.buffer) == 256 && CVPixelBufferGetHeight(got.buffer) == 256) - #expect(CMTimeCompare(got.pts, Self.pts(1)) == 0) - } + private static func isStrictlyIncreasing(_ frames: [Deliveries.Frame]) -> Bool { + zip(frames, frames.dropFirst()).allSatisfy { CMTimeCompare($0.pts, $1.pts) < 0 } + } - @Test("enhancement layer first, then base layer: the pair is composed and delivered at the base layer's PTS", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func pairsWhenEnhancementLayerArrivesFirst() async throws { - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } - decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) - - try await waitFor { deliveries.count == 1 } - let got = try #require(deliveries.snapshot.first) - #expect(got.buffer !== bl) - #expect(CMTimeCompare(got.pts, Self.pts(1)) == 0) - } + // MARK: - Single layer - @Test("six enhancement-layer frames arriving ahead of the base layer all compose", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func pairsWhenEnhancementLayerRunsSixFramesAhead() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } + @Test("without an enhancement layer every base-layer frame is delivered untouched, in order") + func singleLayerBypassesInOrder() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: false) + defer { decoder.close() } + var sent: [CVPixelBuffer] = [] + for i in 0..<5 { + let bl = try #require(Self.baseLayer()) + sent.append(bl) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + } - for i in 0..<6 { - let el = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) + // A bypass is now a `fulfil` onto `OrderedDelivery`'s queue + // (`queue.async`) rather than a synchronous call to `onFrame` on + // whichever thread paired the frame, so `handleBaseLayerFrame` + // returning is no longer a guarantee the frame has been delivered. + try await waitFor { deliveries.count == 5 } + let got = deliveries.snapshot + #expect(got.count == 5) + #expect(zip(got, sent).allSatisfy { $0.buffer === $1 }) + #expect(Self.isStrictlyIncreasing(got)) } - var sent: [CVPixelBuffer] = [] - for i in 0..<6 { + // MARK: - Pairing + + @Test("base layer first, then enhancement layer: the pair is composed and delivered at the base layer's PTS", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func pairsWhenBaseLayerArrivesFirst() async throws { let bl = try #require(Self.baseLayer()) - sent.append(bl) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) - } + let el = try #require(Self.enhancementLayer()) + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } + decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) + + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) + // Not `deliveries.count == 0`: delivery is one more `queue.async` hop + // away, so that would hold whether or not the frame is still waiting. + // `blQueueCount` is synchronous and deterministic: + // `handleBaseLayerFrame` decides queued-vs-ticketed entirely under + // `lock`, so by the time it returns, the frame is provably still + // sitting in `blFrameQueue` (not bypassed, not composed) if and only + // if it is still waiting for its enhancement layer. + #expect(decoder.stateSnapshotForTesting.blQueueCount == 1, "a base-layer frame with a pending enhancement layer must wait for it") + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) + + try await waitFor { deliveries.count == 1 } + let got = try #require(deliveries.snapshot.first) + // Not `got.buffer !== bl`: composing in place delivers the base-layer buffer itself, now composed. + #expect(decoder.composedFrameCount == 1, "expected the composer's output, got the bypassed base layer") + #expect(CVPixelBufferGetWidth(got.buffer) == 256 && CVPixelBufferGetHeight(got.buffer) == 256) + #expect(CMTimeCompare(got.pts, Self.pts(1)) == 0) + } + + @Test("enhancement layer first, then base layer: the pair is composed and delivered at the base layer's PTS", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func pairsWhenEnhancementLayerArrivesFirst() async throws { + let bl = try #require(Self.baseLayer()) + let el = try #require(Self.enhancementLayer()) + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } + decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - try await waitFor { deliveries.count == 6 } - let got = deliveries.snapshot - #expect(got.count == 6) - #expect(zip(got, sent).allSatisfy { $0.buffer !== $1 }, "every frame must use the composed path, not bypass for a missing EL") - #expect(got.enumerated().allSatisfy { CMTimeCompare($0.element.pts, Self.pts(Int64($0.offset))) == 0 }) - } + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) - @Test("layers up to 300 ticks apart still pair; further apart they do not", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func jitterTolerance() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } - - let bl1 = try #require(Self.baseLayer()) - let el1 = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el1, pts: Self.pts(1, offsetTicks: 300)) - decoder.handleBaseLayerFrame(baseBuffer: bl1, pts: Self.pts(1), hdr10Data: nil) - try await waitFor { deliveries.count == 1 } - #expect(deliveries.count == 1) - #expect(deliveries.snapshot.first?.buffer !== bl1) - - let bl2 = try #require(Self.baseLayer()) - let el2 = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(2), params: Self.params(), displayMetadataNAL: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el2, pts: Self.pts(2, offsetTicks: 301)) - decoder.handleBaseLayerFrame(baseBuffer: bl2, pts: Self.pts(2), hdr10Data: nil) - decoder.quiesceForTesting() - #expect(deliveries.count == 1, "301 ticks apart must not pair") - } + try await waitFor { deliveries.count == 1 } + let got = try #require(deliveries.snapshot.first) + #expect(decoder.composedFrameCount == 1, "the pair must be composed, not bypassed") + #expect(CMTimeCompare(got.pts, Self.pts(1)) == 0) + } - @Test("a pair with no cached RPU is not composed with invented coefficients: the base layer goes out", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func missingRPUBypassesToBaseLayer() async throws { - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } + @Test("six enhancement-layer frames arriving ahead of the base layer all compose", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func pairsWhenEnhancementLayerRunsSixFramesAhead() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) + for i in 0..<6 { + let el = try #require(Self.enhancementLayer()) + decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) + } - try await waitFor { deliveries.count == 1 } - #expect(deliveries.snapshot.first?.buffer === bl) - } + var sent: [CVPixelBuffer] = [] + for i in 0..<6 { + let bl = try #require(Self.baseLayer()) + sent.append(bl) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + } - // MARK: - Ordering + try await waitFor { deliveries.count == 6 } + let got = deliveries.snapshot + #expect(got.count == 6) + #expect(decoder.composedFrameCount == 6, "every frame must use the composed path, not bypass for a missing EL") + #expect(got.enumerated().allSatisfy { CMTimeCompare($0.element.pts, Self.pts(Int64($0.offset))) == 0 }) + } - @Test("a composition still on the GPU is not overtaken by a later frame that bypasses synchronously", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func asyncCompositionKeepsItsPlace() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } + @Test("layers up to 300 ticks apart still pair; further apart they do not", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func jitterTolerance() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } - for round in 0..<8 { - let composed = Int64(round * 2), bypassed = Int64(round * 2 + 1) - let bl0 = try #require(Self.baseLayer()) - let el0 = try #require(Self.enhancementLayer()) let bl1 = try #require(Self.baseLayer()) let el1 = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(composed), params: Self.params(), displayMetadataNAL: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el0, pts: Self.pts(composed)) - decoder.handleBaseLayerFrame(baseBuffer: bl0, pts: Self.pts(composed), hdr10Data: nil) - // No RPU for this one: it bypasses on the calling thread, ahead of the GPU. - decoder.handleEnhancementLayerFrame(elBuffer: el1, pts: Self.pts(bypassed)) - decoder.handleBaseLayerFrame(baseBuffer: bl1, pts: Self.pts(bypassed), hdr10Data: nil) - } - - try await waitFor { deliveries.count == 16 } - let got = deliveries.snapshot - #expect(got.count == 16) - #expect(Self.isStrictlyIncreasing(got)) - } - - @Test("a base layer whose enhancement layer never arrives is still delivered, in order", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func orphanedBaseLayerIsEventuallyDelivered() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } - - let frames = 40 - for i in 0..= frames - 16 } - decoder.quiesceForTesting() - let got = deliveries.snapshot - // Whatever the queue bound is, a lost enhancement layer must not swallow the picture: - // all but a bounded tail of frames go out, as base layer, oldest first. - #expect(got.count >= frames - 16) - #expect(Self.isStrictlyIncreasing(got)) - } + let el = try #require(Self.enhancementLayer()) + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } + + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) + + try await waitFor { deliveries.count == 1 } + #expect(deliveries.snapshot.first?.buffer === bl) + } + + // MARK: - Ordering + + @Test("a composition still on the GPU is not overtaken by a later frame that bypasses synchronously", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func asyncCompositionKeepsItsPlace() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } + + for round in 0..<8 { + let composed = Int64(round * 2), bypassed = Int64(round * 2 + 1) + let bl0 = try #require(Self.baseLayer()) + let el0 = try #require(Self.enhancementLayer()) + let bl1 = try #require(Self.baseLayer()) + let el1 = try #require(Self.enhancementLayer()) + decoder.seedRPUForTesting(pts: Self.pts(composed), params: Self.params(), displayMetadataNAL: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el0, pts: Self.pts(composed)) + decoder.handleBaseLayerFrame(baseBuffer: bl0, pts: Self.pts(composed), hdr10Data: nil) + // No RPU for this one: it bypasses on the calling thread, ahead of the GPU. + decoder.handleEnhancementLayerFrame(elBuffer: el1, pts: Self.pts(bypassed)) + decoder.handleBaseLayerFrame(baseBuffer: bl1, pts: Self.pts(bypassed), hdr10Data: nil) + } - @Test("enhancement-layer frames with no base layer are bounded: the oldest can no longer pair", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func orphanedEnhancementLayersAreBounded() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } + try await waitFor { deliveries.count == 16 } + let got = deliveries.snapshot + #expect(got.count == 16) + #expect(Self.isStrictlyIncreasing(got)) + } - for i in 0..<40 { - let el = try #require(Self.enhancementLayer()) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) - } - let oldest = try #require(Self.baseLayer()) - let newest = try #require(Self.baseLayer()) - decoder.seedRPUForTesting(pts: Self.pts(0), params: Self.params(), displayMetadataNAL: nil) - decoder.seedRPUForTesting(pts: Self.pts(39), params: Self.params(), displayMetadataNAL: nil) - - decoder.handleBaseLayerFrame(baseBuffer: oldest, pts: Self.pts(0), hdr10Data: nil) - decoder.handleBaseLayerFrame(baseBuffer: newest, pts: Self.pts(39), hdr10Data: nil) - // Frame 0's enhancement layer is gone, so it waits for `maxLayerSkewFrames` further base-layer - // arrivals and then goes out alone; frame 39 is delivered behind it, in order. Without - // these arrivals nothing is delivered at all, which an earlier version of this test read as a - // pass: its bounded wait ran out and `first?.buffer !== newest` held for `nil`. - for i in 0..= 2 } - let got = deliveries.snapshot - #expect(CMTimeCompare(got[0].pts, Self.pts(0)) == 0) - #expect(got[0].buffer === oldest, "the enhancement layer for frame 0 should have been evicted: it goes out as its base layer") - #expect(CMTimeCompare(got[1].pts, Self.pts(39)) == 0) - #expect(got[1].buffer !== newest, "the newest enhancement layer should still pair") - } + @Test("a base layer whose enhancement layer never arrives is still delivered, in order", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func orphanedBaseLayerIsEventuallyDelivered() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } - // MARK: - Dolby Vision display metadata + let frames = 40 + for i in 0..= frames - 16 } + decoder.quiesceForTesting() + let got = deliveries.snapshot + // Whatever the queue bound is, a lost enhancement layer must not swallow the picture: + // all but a bounded tail of frames go out, as base layer, oldest first. + #expect(got.count >= frames - 16) + #expect(Self.isStrictlyIncreasing(got)) + } + + @Test("enhancement-layer frames with no base layer are bounded: the oldest can no longer pair", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func orphanedEnhancementLayersAreBounded() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } + + for i in 0..<40 { + let el = try #require(Self.enhancementLayer()) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) + } + let oldest = try #require(Self.baseLayer()) + let newest = try #require(Self.baseLayer()) + decoder.seedRPUForTesting(pts: Self.pts(0), params: Self.params(), displayMetadataNAL: nil) + decoder.seedRPUForTesting(pts: Self.pts(39), params: Self.params(), displayMetadataNAL: nil) + + decoder.handleBaseLayerFrame(baseBuffer: oldest, pts: Self.pts(0), hdr10Data: nil) + decoder.handleBaseLayerFrame(baseBuffer: newest, pts: Self.pts(39), hdr10Data: nil) + // Frame 0's enhancement layer is gone, so it waits for `maxLayerSkewFrames` further base-layer + // arrivals and then goes out alone; frame 39 is delivered behind it, in order. Without + // these arrivals nothing is delivered at all, which an earlier version of this test read as a + // pass: its bounded wait ran out and `first?.buffer !== newest` held for `nil`. + for i in 0..= 2 } + let got = deliveries.snapshot + #expect(CMTimeCompare(got[0].pts, Self.pts(0)) == 0) + #expect(got[0].buffer === oldest, "the enhancement layer for frame 0 should have been evicted: it goes out as its base layer") + #expect(CMTimeCompare(got[1].pts, Self.pts(39)) == 0) + #expect(decoder.composedFrameCount == 1, "the newest enhancement layer should still pair") + } + + // MARK: - Dolby Vision display metadata + + @Test("every delivered frame carries its own RPU payload; a frame without one borrows the previous frame's", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func displayMetadataTravelsWithItsFrame() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } + + for i in 0..<4 { + let bl = try #require(Self.baseLayer()) + let el = try #require(Self.enhancementLayer()) + let nal = Self.rpuNAL(tag: UInt8(i)) + decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nal) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + } + // Frame 4 has no RPU at all: it borrows frame 3's parameters and payload (E1), and the stale + // attachment its recycled buffer arrived with must be replaced, not kept. + decoder.seedRPUForTesting(pts: Self.pts(4), params: nil, displayMetadataNAL: nil) + let recycled = try #require(Self.baseLayer()) + DoviDisplayMetadata.attach(rpuNAL: Self.rpuNAL(tag: 0xEE), to: recycled) + decoder.handleBaseLayerFrame(baseBuffer: recycled, pts: Self.pts(4), hdr10Data: nil) + let el4 = try #require(Self.enhancementLayer()) + decoder.handleEnhancementLayerFrame(elBuffer: el4, pts: Self.pts(4)) + + try await waitFor { deliveries.count == 5 } + let got = deliveries.snapshot + #expect(got.count == 5) + for i in 0..<4 where i < got.count { + #expect(got[i].rpuPayload == Data(Self.rpuNAL(tag: UInt8(i)).dropFirst(2)), "frame \(i) carries the wrong RPU") + #expect((got[i].compatibilityID as? String) == "1", "DolbyCompatibilityID must be the CFString \"1\"") + } + if got.count == 5 { + #expect(got[4].rpuPayload == Data(Self.rpuNAL(tag: 3).dropFirst(2)), "a borrowed frame carries the previous payload") + #expect((got[4].compatibilityID as? String) == "1") + } + } - for i in 0..<4 { - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - let nal = Self.rpuNAL(tag: UInt8(i)) - decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nal) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) - } - // Frame 4 has no RPU at all: it borrows frame 3's parameters and payload (E1), and the stale - // attachment its recycled buffer arrived with must be replaced, not kept. - decoder.seedRPUForTesting(pts: Self.pts(4), params: nil, displayMetadataNAL: nil) - let recycled = try #require(Self.baseLayer()) - DoviDisplayMetadata.attach(rpuNAL: Self.rpuNAL(tag: 0xEE), to: recycled) - decoder.handleBaseLayerFrame(baseBuffer: recycled, pts: Self.pts(4), hdr10Data: nil) - let el4 = try #require(Self.enhancementLayer()) - decoder.handleEnhancementLayerFrame(elBuffer: el4, pts: Self.pts(4)) - - try await waitFor { deliveries.count == 5 } - let got = deliveries.snapshot - #expect(got.count == 5) - for i in 0..<4 where i < got.count { - #expect(got[i].rpuPayload == Data(Self.rpuNAL(tag: UInt8(i)).dropFirst(2)), "frame \(i) carries the wrong RPU") - #expect((got[i].compatibilityID as? String) == "1", "DolbyCompatibilityID must be the CFString \"1\"") - } - if got.count == 5 { - #expect(got[4].rpuPayload == Data(Self.rpuNAL(tag: 3).dropFirst(2)), "a borrowed frame carries the previous payload") - #expect((got[4].compatibilityID as? String) == "1") + @Test("a run of use_prev_vdr_rpu_flag frames keeps composing with the previous mapping, past the corrupt-RPU borrow limit", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func usePreviousRunIsNotCapped() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } + + let frames = DualLayerVideoDecoder.maxBorrowedParamsRun + 6 + var inputs: [CVPixelBuffer] = [] + for i in 0.. UInt32? { + lock.lock(); defer { lock.unlock() } + return _blMaxByBase[ObjectIdentifier(base)] + } + func flags(of base: CVPixelBuffer) -> DoviComposerFlags? { + lock.lock(); defer { lock.unlock() } + return _flagsByBase[ObjectIdentifier(base)] + } + func acquireSlot() {} + func releaseSlot() {} + func flush() {} + func composeAsync(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, params: DoviMetalParams, + frameIndex: UInt32, completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { + composeAsyncWithAcquiredSlot(baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, + params: params, frameIndex: frameIndex, completion: completion) + } + func composeAsyncWithAcquiredSlot(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { + lock.lock() + _blMaxByBase[ObjectIdentifier(baseBuffer)] = params.blMax + _flagsByBase[ObjectIdentifier(baseBuffer)] = params.flags + lock.unlock() + completion(baseBuffer) + } } - } - @Test("a run of use_prev_vdr_rpu_flag frames keeps composing with the previous mapping, past the corrupt-RPU borrow limit", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func usePreviousRunIsNotCapped() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } + @Test("the previous mapping is the previous frame in the stream, not the previous frame to finish pairing") + func previousMappingFollowsDecodeOrder() async throws { + let composer = ParamsRecordingComposer() + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: composer) + defer { decoder.close() } + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + + func mapping(_ tag: UInt32) -> DoviMetalParams { var p = Self.params(); p.blMax = tag; return p } + // Packets, in decode order: A, B, "same as before", an RPU that failed, then C. + decoder.seedRPUForTesting(pts: Self.pts(0), params: mapping(100), displayMetadataNAL: nil) + decoder.seedRPUForTesting(pts: Self.pts(1), params: mapping(200), displayMetadataNAL: nil) + decoder.seedRPUForTesting(pts: Self.pts(2), params: nil, displayMetadataNAL: nil, usesPrevious: true) + decoder.seedRPUForTesting(pts: Self.pts(3), params: nil, displayMetadataNAL: nil) + decoder.seedRPUForTesting(pts: Self.pts(4), params: mapping(300), displayMetadataNAL: nil) + + // The layers pair in a different order: 0, then 4 and 2 and 3 before 1. + var bases: [Int: CVPixelBuffer] = [:] + for frame in [0, 4, 2, 3, 1] { + let bl = try #require(Self.baseLayer()) + let el = try #require(Self.enhancementLayer()) + bases[frame] = bl + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(frame))) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(frame)), hdr10Data: nil) + } + try await waitFor { deliveries.count == 5 } + let blMax = (0..<5).map { composer.blMax(of: bases[$0]!) } + #expect(blMax == [100, 200, 200, 200, 300]) + } + + @Test("a use_prev frame keeps the previous mapping but takes its own disable_residual_flag") + func usePreviousTakesOwnResidualFlag() async throws { + let composer = ParamsRecordingComposer() + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: composer) + defer { decoder.close() } + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + + var full = Self.params(); full.blMax = 100 + var noResidual = full; noResidual.flags.insert(.disableResidual) + // A residual-less RPU, then "same mapping" frames that switch the residual on and off again. + decoder.seedRPUForTesting(pts: Self.pts(0), params: noResidual, displayMetadataNAL: nil) + decoder.seedRPUForTesting(pts: Self.pts(1), params: nil, displayMetadataNAL: nil, usesPrevious: true, disableResidual: false) + decoder.seedRPUForTesting(pts: Self.pts(2), params: nil, displayMetadataNAL: nil, usesPrevious: true, disableResidual: true) + + var bases: [CVPixelBuffer] = [] + for frame in 0..<3 { + let bl = try #require(Self.baseLayer()) + let el = try #require(Self.enhancementLayer()) + bases.append(bl) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(frame))) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(frame)), hdr10Data: nil) + } + try await waitFor { deliveries.count == 3 } + #expect(bases.map { composer.blMax(of: $0) } == [100, 100, 100], "the mapping carries over") + #expect(bases.map { composer.flags(of: $0)?.contains(.disableResidual) } == [true, false, true], + "each frame's own disable_residual_flag wins over the borrowed parameters'") + } + + #if DEBUG + @Test("side data recorded for post-seek packets while flush() drains survives the flush and composes") + func sideDataRecordedDuringTheDrainSurvivesFlush() async throws { + let composer = ParamsRecordingComposer() + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: composer) + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + defer { + decoder.flushRaceHookForTesting = nil + decoder.close() + } - let frames = DualLayerVideoDecoder.maxBorrowedParamsRun + 6 - var inputs: [CVPixelBuffer] = [] - for i in 0.. DoviMetalParams { var p = Self.params(); p.blMax = tag; return p } + // A pre-seek RPU that the flush must discard, and a post-seek one the demux thread records + // while the hardware drain is still running (the hook runs between the two critical sections). + decoder.seedRPUForTesting(pts: Self.pts(0), params: mapping(100), displayMetadataNAL: nil) + decoder.flushRaceHookForTesting = { [weak decoder] in + decoder?.seedRPUForTesting(pts: Self.pts(10), params: mapping(777), displayMetadataNAL: nil) + } + decoder.flush() + + let staleBL = try #require(Self.baseLayer()) + let staleEL = try #require(Self.enhancementLayer()) let bl = try #require(Self.baseLayer()) let el = try #require(Self.enhancementLayer()) - inputs.append(bl) - if i == 0 { - decoder.seedRPUForTesting(pts: Self.pts(0), params: Self.params(), displayMetadataNAL: Self.rpuNAL(tag: 0)) - } else { - decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: nil, displayMetadataNAL: nil, usesPrevious: true) - } - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) - } - try await waitFor { deliveries.count == frames } - let got = deliveries.snapshot - #expect(got.count == frames) - // A bypassed frame is delivered as the base buffer itself; a composed one is a new pool buffer. - for (i, f) in got.enumerated() where i < inputs.count { - #expect(f.buffer !== inputs[i], "frame \(i) fell back to the base layer") - } - } + decoder.handleEnhancementLayerFrame(elBuffer: staleEL, pts: Self.pts(0)) + decoder.handleBaseLayerFrame(baseBuffer: staleBL, pts: Self.pts(0), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(10)) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(10), hdr10Data: nil) + try await waitFor { deliveries.count == 2 } + #expect(composer.blMax(of: bl) == 777, "the RPU recorded during the drain must compose its frame") + #expect(composer.blMax(of: staleBL) == nil, "the pre-seek RPU is gone") + } + + @Test("two flushes gated at the same point run one after the other: no interleaving, no stall, no stale delivery") + func concurrentFlushesAreSerialised() async throws { + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: false) + defer { + decoder.flushRaceHookForTesting = nil + decoder.close() + } + for i in 0..<3 { + let bl = try #require(Self.baseLayer()) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + } + try await waitFor { deliveries.count == 3 } + + let firstInHook = BoolFlag() + let hookCalls = LockedCounter() + let release = DispatchSemaphore(value: 0) + decoder.flushRaceHookForTesting = { + // Only the first flush to get here is held; the second must never get this far while it is. + if hookCalls.incrementAndCheckFirst() { + firstInHook.set() + release.wait() + } + } + let epochBefore = decoder.feedEpoch + let firstDone = BoolFlag() + let secondDone = BoolFlag() + BlockingWork.detached { decoder.flush(); firstDone.set() } + try await waitFor { firstInHook.isSet } + #expect(decoder.feedEpoch == epochBefore + 1) - /// Hands compositions straight back, remembering which parameters each base buffer was composed with. - private final class ParamsRecordingComposer: FelComposing, @unchecked Sendable { - private let lock = NSLock() - private var _blMaxByBase: [ObjectIdentifier: UInt32] = [:] - private var _flagsByBase: [ObjectIdentifier: DoviComposerFlags] = [:] - func blMax(of base: CVPixelBuffer) -> UInt32? { - lock.lock(); defer { lock.unlock() } - return _blMaxByBase[ObjectIdentifier(base)] - } - func flags(of base: CVPixelBuffer) -> DoviComposerFlags? { - lock.lock(); defer { lock.unlock() } - return _flagsByBase[ObjectIdentifier(base)] - } - func acquireSlot() {} - func releaseSlot() {} - func flush() {} - func composeAsync(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, params: DoviMetalParams, - frameIndex: UInt32, completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { - composeAsyncWithAcquiredSlot(baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, - params: params, frameIndex: frameIndex, completion: completion) - } - func composeAsyncWithAcquiredSlot(baseBuffer: CVPixelBuffer, enhancementBuffer: CVPixelBuffer, - params: DoviMetalParams, frameIndex: UInt32, - completion: @escaping @Sendable (CVPixelBuffer?) -> Void) { - lock.lock() - _blMaxByBase[ObjectIdentifier(baseBuffer)] = params.blMax - _flagsByBase[ObjectIdentifier(baseBuffer)] = params.flags - lock.unlock() - completion(baseBuffer) - } - } + let secondArrived = BoolFlag() + decoder.flushWillTakeLifecycleLockHookForTesting = { secondArrived.set() } + BlockingWork.detached { decoder.flush(); secondDone.set() } + // The second flush must stay out of its critical section (the epoch must not move, `draining` + // must stay owned by the first) while the first is held. Wait until it has reached the lock, + // by a hook rather than by a delay, then check: if the lock were missing it would be past + // the epoch bump within a few instructions, and `release` is still unsignalled, so the first + // flush cannot be what moved it. + try await waitFor { secondArrived.isSet } + #expect(!secondDone.isSet) + #expect(decoder.feedEpoch == epochBefore + 1, "the second flush interleaved with the first") + + release.signal() + try await waitFor { firstDone.isSet && secondDone.isSet } + #expect(decoder.feedEpoch == epochBefore + 2) + + // Delivery works afterwards: new frames take tickets under the final epoch and come out in order. + for i in 3..<6 { + let bl = try #require(Self.baseLayer()) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + } + try await waitFor { deliveries.count == 6 } + let got = deliveries.snapshot + #expect(got.count == 6, "a frame was delivered twice or a ticket was lost") + #expect(Self.isStrictlyIncreasing(got)) + } + #endif + + @Test("composedFrameCount counts compositions only, not bypasses, and survives flush()") + func composedFrameCountIgnoresBypassesAndFlush() async throws { + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: ParamsRecordingComposer()) + defer { decoder.close() } + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + #expect(decoder.composedFrameCount == 0) + + // Frame 0 has an RPU and its enhancement layer: composed. Frame 1 has no RPU: bypassed. + decoder.seedRPUForTesting(pts: Self.pts(0), params: Self.params(), displayMetadataNAL: nil) + for frame in 0..<2 { + let bl = try #require(Self.baseLayer()) + let el = try #require(Self.enhancementLayer()) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(frame))) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(frame)), hdr10Data: nil) + } + try await waitFor { deliveries.count == 2 } + #expect(decoder.composedFrameCount == 1) + decoder.flush() + #expect(decoder.composedFrameCount == 1, "the 1 Hz summary and flush reset their own counters, not this one") + } + + @Test("frames without a timestamp pair with nothing, and never trap the key arithmetic") + func missingTimestampNeverMatchesOrTraps() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } + let waitingBL = try #require(Self.baseLayer()) + let waitingEL = try #require(Self.enhancementLayer()) + let noPtsBL = try #require(Self.baseLayer()) + let noPtsEL = try #require(Self.enhancementLayer()) + + // A base-layer frame and an enhancement-layer frame are each waiting on a partner at a normal time. + decoder.handleBaseLayerFrame(baseBuffer: waitingBL, pts: Self.pts(5), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: waitingEL, pts: Self.pts(9)) + // Then a frame of each layer arrives with no PTS (`AV_NOPTS_VALUE` is `Int64.min`). + decoder.handleEnhancementLayerFrame(elBuffer: noPtsEL, pts: .invalid) + decoder.handleBaseLayerFrame(baseBuffer: noPtsBL, pts: .invalid, hdr10Data: nil) + + // The enhancement layer without a timestamp was dropped, and the two waiting frames are untouched. + let snapshot = decoder.stateSnapshotForTesting + #expect(snapshot.elQueueCount == 1) + #expect(snapshot.blQueueCount == 1) + // The base layer without a timestamp bypassed at once, but under its own ticket: it stays behind + // the waiting frame that arrived first. + #expect(deliveries.count == 0) + // Completing the waiting pair releases both, in arrival order. + decoder.handleEnhancementLayerFrame(elBuffer: Self.enhancementLayer()!, pts: Self.pts(5)) + try await waitFor { deliveries.count == 2 } + #expect(deliveries.snapshot.last?.buffer === noPtsBL) + } + + @Test("the side-data ring does not trap on, or match, a missing key") + func ringIgnoresMissingKeys() { + var ring = FrameSideDataRing() + ring.insert(key: 1_000_000, params: DoviMetalParams(), dmNAL: [1, 2, 3]) + ring.insert(key: -5_000_000_000, params: DoviMetalParams()) + #expect(ring.find(key: Int64.min) == nil) + #expect(ring.removeParams(key: Int64.min) == nil) + #expect(ring.removeDisplayMetadata(key: Int64.min) == nil) + #expect(ring.count == 2) + #expect(FrameSideDataRing.distance(Int64.max, -Int64.max) == nil) + #expect(FrameSideDataRing.distance(10, 4) == 6) + #expect(!FrameSideDataRing.isWithinJitter(Int64.min, Int64.min)) + #expect(FrameSideDataRing.isWithinJitter(1000, 1300)) + #expect(!FrameSideDataRing.isWithinJitter(1000, 1301)) + } + + // MARK: - Flush and close + + @Test("nothing composed before a flush is delivered after the flush returns", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func flushRetiresOutstandingFrames() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + defer { decoder.close() } + + for i in 0..<6 { + let bl = try #require(Self.baseLayer()) + let el = try #require(Self.enhancementLayer()) + decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + } + decoder.flush() + deliveries.setMarker() - @Test("the previous mapping is the previous frame in the stream, not the previous frame to finish pairing") - func previousMappingFollowsDecodeOrder() async throws { - let composer = ParamsRecordingComposer() - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: composer) - defer { decoder.close() } - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - - func mapping(_ tag: UInt32) -> DoviMetalParams { var p = Self.params(); p.blMax = tag; return p } - // Packets, in decode order: A, B, "same as before", an RPU that failed, then C. - decoder.seedRPUForTesting(pts: Self.pts(0), params: mapping(100), displayMetadataNAL: nil) - decoder.seedRPUForTesting(pts: Self.pts(1), params: mapping(200), displayMetadataNAL: nil) - decoder.seedRPUForTesting(pts: Self.pts(2), params: nil, displayMetadataNAL: nil, usesPrevious: true) - decoder.seedRPUForTesting(pts: Self.pts(3), params: nil, displayMetadataNAL: nil) - decoder.seedRPUForTesting(pts: Self.pts(4), params: mapping(300), displayMetadataNAL: nil) - - // The layers pair in a different order: 0, then 4 and 2 and 3 before 1. - var bases: [Int: CVPixelBuffer] = [:] - for frame in [0, 4, 2, 3, 1] { - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - bases[frame] = bl - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(frame))) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(frame)), hdr10Data: nil) + // The decoder is usable after a flush: a fresh single-layer frame goes straight out. + decoder.primeForTesting(enhancementLayer: false) + let after = try #require(Self.baseLayer()) + decoder.handleBaseLayerFrame(baseBuffer: after, pts: Self.pts(100), hdr10Data: nil) + decoder.quiesceForTesting() + + let late = deliveries.snapshot.filter { $0.afterMarker } + #expect(late.count == 1) + #expect(late.first?.buffer === after) } - try await waitFor { deliveries.count == 5 } - let blMax = (0..<5).map { composer.blMax(of: bases[$0]!) } - #expect(blMax == [100, 200, 200, 200, 300]) - } - @Test("a use_prev frame keeps the previous mapping but takes its own disable_residual_flag") - func usePreviousTakesOwnResidualFlag() async throws { - let composer = ParamsRecordingComposer() - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: composer) - defer { decoder.close() } - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - - var full = Self.params(); full.blMax = 100 - var noResidual = full; noResidual.flags.insert(.disableResidual) - // A residual-less RPU, then "same mapping" frames that switch the residual on and off again. - decoder.seedRPUForTesting(pts: Self.pts(0), params: noResidual, displayMetadataNAL: nil) - decoder.seedRPUForTesting(pts: Self.pts(1), params: nil, displayMetadataNAL: nil, usesPrevious: true, disableResidual: false) - decoder.seedRPUForTesting(pts: Self.pts(2), params: nil, displayMetadataNAL: nil, usesPrevious: true, disableResidual: true) - - var bases: [CVPixelBuffer] = [] - for frame in 0..<3 { - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - bases.append(bl) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(frame))) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(frame)), hdr10Data: nil) - } - try await waitFor { deliveries.count == 3 } - #expect(bases.map { composer.blMax(of: $0) } == [100, 100, 100], "the mapping carries over") - #expect(bases.map { composer.flags(of: $0)?.contains(.disableResidual) } == [true, false, true], - "each frame's own disable_residual_flag wins over the borrowed parameters'") - } + @Test("nothing is delivered after close returns", .enabled(if: Self.metalAvailable, "needs a Metal device")) + func closeStopsDelivery() async throws { + let deliveries = Deliveries() + let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - #if DEBUG - @Test("side data recorded for post-seek packets while flush() drains survives the flush and composes") - func sideDataRecordedDuringTheDrainSurvivesFlush() async throws { - let composer = ParamsRecordingComposer() - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: composer) - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - defer { - decoder.flushRaceHookForTesting = nil + for i in 0..<6 { + let bl = try #require(Self.baseLayer()) + let el = try #require(Self.enhancementLayer()) + decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + } decoder.close() + deliveries.setMarker() + decoder.quiesceForTesting() + + #expect(deliveries.snapshot.allSatisfy { !$0.afterMarker }) } - func mapping(_ tag: UInt32) -> DoviMetalParams { var p = Self.params(); p.blMax = tag; return p } - // A pre-seek RPU that the flush must discard, and a post-seek one the demux thread records - // while the hardware drain is still running (the hook runs between the two critical sections). - decoder.seedRPUForTesting(pts: Self.pts(0), params: mapping(100), displayMetadataNAL: nil) - decoder.flushRaceHookForTesting = { [weak decoder] in - decoder?.seedRPUForTesting(pts: Self.pts(10), params: mapping(777), displayMetadataNAL: nil) - } - decoder.flush() - - let staleBL = try #require(Self.baseLayer()) - let staleEL = try #require(Self.enhancementLayer()) - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - decoder.handleEnhancementLayerFrame(elBuffer: staleEL, pts: Self.pts(0)) - decoder.handleBaseLayerFrame(baseBuffer: staleBL, pts: Self.pts(0), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(10)) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(10), hdr10Data: nil) - try await waitFor { deliveries.count == 2 } - #expect(composer.blMax(of: bl) == 777, "the RPU recorded during the drain must compose its frame") - #expect(composer.blMax(of: staleBL) == nil, "the pre-seek RPU is gone") - } + @Test("close resets stream state and the decoder refuses to reopen") + func closeResetsStreamStateAndRefusesReopen() async throws { + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder() + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + + // Latch the MEL state a reopened instance must never inherit. + decoder.primeStreamForTesting(.mel) + #expect(decoder.streamClassificationForTesting == .mel) - @Test("two flushes gated at the same point run one after the other: no interleaving, no stall, no stale delivery") - func concurrentFlushesAreSerialised() async throws { - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: false) - defer { - decoder.flushRaceHookForTesting = nil decoder.close() - } - for i in 0..<3 { - let bl = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) - } - try await waitFor { deliveries.count == 3 } - - let firstInHook = BoolFlag() - let hookCalls = LockedCounter() - let release = DispatchSemaphore(value: 0) - decoder.flushRaceHookForTesting = { - // Only the first flush to get here is held; the second must never get this far while it is. - if hookCalls.incrementAndCheckFirst() { - firstInHook.set() - release.wait() - } - } - let epochBefore = decoder.feedEpoch - let firstDone = BoolFlag() - let secondDone = BoolFlag() - BlockingWork.detached { decoder.flush(); firstDone.set() } - try await waitFor { firstInHook.isSet } - #expect(decoder.feedEpoch == epochBefore + 1) - - let secondArrived = BoolFlag() - decoder.flushWillTakeLifecycleLockHookForTesting = { secondArrived.set() } - BlockingWork.detached { decoder.flush(); secondDone.set() } - // The second flush must stay out of its critical section (the epoch must not move, `draining` - // must stay owned by the first) while the first is held. Wait until it has reached the lock, - // by a hook rather than by a delay, then check: if the lock were missing it would be past - // the epoch bump within a few instructions, and `release` is still unsignalled, so the first - // flush cannot be what moved it. - try await waitFor { secondArrived.isSet } - #expect(!secondDone.isSet) - #expect(decoder.feedEpoch == epochBefore + 1, "the second flush interleaved with the first") - - release.signal() - try await waitFor { firstDone.isSet && secondDone.isSet } - #expect(decoder.feedEpoch == epochBefore + 2) - - // Delivery works afterwards: new frames take tickets under the final epoch and come out in order. - for i in 3..<6 { - let bl = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) - } - try await waitFor { deliveries.count == 6 } - let got = deliveries.snapshot - #expect(got.count == 6, "a frame was delivered twice or a ticket was lost") - #expect(Self.isStrictlyIncreasing(got)) - } - #endif - - @Test("composedFrameCount counts compositions only, not bypasses, and survives flush()") - func composedFrameCountIgnoresBypassesAndFlush() async throws { - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: ParamsRecordingComposer()) - defer { decoder.close() } - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - #expect(decoder.composedFrameCount == 0) - - // Frame 0 has an RPU and its enhancement layer: composed. Frame 1 has no RPU: bypassed. - decoder.seedRPUForTesting(pts: Self.pts(0), params: Self.params(), displayMetadataNAL: nil) - for frame in 0..<2 { - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(frame))) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(frame)), hdr10Data: nil) - } - try await waitFor { deliveries.count == 2 } - #expect(decoder.composedFrameCount == 1) - decoder.flush() - #expect(decoder.composedFrameCount == 1, "the 1 Hz summary and flush reset their own counters, not this one") - } - @Test("frames without a timestamp pair with nothing, and never trap the key arithmetic") - func missingTimestampNeverMatchesOrTraps() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } - let waitingBL = try #require(Self.baseLayer()) - let waitingEL = try #require(Self.enhancementLayer()) - let noPtsBL = try #require(Self.baseLayer()) - let noPtsEL = try #require(Self.enhancementLayer()) - - // A base-layer frame and an enhancement-layer frame are each waiting on a partner at a normal time. - decoder.handleBaseLayerFrame(baseBuffer: waitingBL, pts: Self.pts(5), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: waitingEL, pts: Self.pts(9)) - // Then a frame of each layer arrives with no PTS (`AV_NOPTS_VALUE` is `Int64.min`). - decoder.handleEnhancementLayerFrame(elBuffer: noPtsEL, pts: .invalid) - decoder.handleBaseLayerFrame(baseBuffer: noPtsBL, pts: .invalid, hdr10Data: nil) - - // The enhancement layer without a timestamp was dropped, and the two waiting frames are untouched. - let snapshot = decoder.stateSnapshotForTesting - #expect(snapshot.elQueueCount == 1) - #expect(snapshot.blQueueCount == 1) - // The base layer without a timestamp bypassed at once, but under its own ticket: it stays behind - // the waiting frame that arrived first. - #expect(deliveries.count == 0) - // Completing the waiting pair releases both, in arrival order. - decoder.handleEnhancementLayerFrame(elBuffer: Self.enhancementLayer()!, pts: Self.pts(5)) - try await waitFor { deliveries.count == 2 } - #expect(deliveries.snapshot.last?.buffer === noPtsBL) - } + let snap = decoder.stateSnapshotForTesting + #expect(snap.isClosed) + #expect(!snap.isFEL && !snap.isMEL && !snap.streamClassified) + #expect(!snap.primaryDecoderInitialized) + #expect(!snap.enhancementEngaged) + #expect(snap.frameIndex == 0) + #expect(snap.blQueueCount == 0) + #expect(snap.elQueueCount == 0) + #expect(snap.rpuCacheCount == 0) + #expect(snap.dmRpuCacheCount == 0) + #expect(snap.deliveryPendingCount == 0) + #expect(snap.deliveryNextTicket == 0) + #expect(snap.deliveryNextToEmit == 0) + // Additional fields made explicit in snapshot (cheap to check): + #expect(!snap.draining) + #expect(snap.timeBaseNum == 1 && snap.timeBaseDen == 90000) + #expect(!snap.hasBLVPS && !snap.hasBLSPS && !snap.hasBLPPS) + #expect(!snap.hasELVPS && !snap.hasELSPS && !snap.hasELPPS) + #expect(!snap.hasSkipUntilPTS) + #expect(!snap.hasOnFrame) + + // Single use: `open` after `close` refuses. `open` checks the flag + // before touching `stream`, so uninitialized storage suffices here. + let raw = UnsafeMutableRawPointer.allocate( + byteCount: MemoryLayout.size, + alignment: MemoryLayout.alignment) + defer { raw.deallocate() } + let stream = raw.assumingMemoryBound(to: AVStream.self) + do { + try decoder.open(stream: stream) { _, _, _ in } + Issue.record("open after close must refuse with VideoDecoderError.decoderClosed") + } catch VideoDecoderError.decoderClosed { + // Expected: single-use decoder. + } - @Test("the side-data ring does not trap on, or match, a missing key") - func ringIgnoresMissingKeys() { - var ring = FrameSideDataRing() - ring.insert(key: 1_000_000, params: DoviMetalParams(), dmNAL: [1, 2, 3]) - ring.insert(key: -5_000_000_000, params: DoviMetalParams()) - #expect(ring.find(key: Int64.min) == nil) - #expect(ring.removeParams(key: Int64.min) == nil) - #expect(ring.removeDisplayMetadata(key: Int64.min) == nil) - #expect(ring.count == 2) - #expect(FrameSideDataRing.distance(Int64.max, -Int64.max) == nil) - #expect(FrameSideDataRing.distance(10, 4) == 6) - #expect(!FrameSideDataRing.isWithinJitter(Int64.min, Int64.min)) - #expect(FrameSideDataRing.isWithinJitter(1000, 1300)) - #expect(!FrameSideDataRing.isWithinJitter(1000, 1301)) - } + // A post-close base-layer frame must not reach the cleared `onFrame`. + // `isClosed` gates `handleBaseLayerFrame` before any ticket is issued; + // settling before the negative check makes that hold by construction, + // as in every other post-teardown check in this file. + let bl = try #require(Self.baseLayer()) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(0), hdr10Data: nil) + decoder.quiesceForTesting() + #expect(deliveries.count == 0) + #expect(decoder.stateSnapshotForTesting.deliveryPendingCount == 0) + + // decode-after-close early-returns before touching the packet payload: + // the entry gate refuses without forwarding to either sub-decoder. + let packet = try #require(av_packet_alloc()) + defer { var p: UnsafeMutablePointer? = packet; av_packet_free(&p) } + decoder.decode(packet: packet) + decoder.decode(packet: packet, epoch: decoder.feedEpoch) + // No sub-decoders to check here (real decoders), but entry gate blocks. + + // flush-after-close is a no-op: still closed, epoch untouched, silent. + decoder.flush() + #expect(decoder.stateSnapshotForTesting.isClosed) + #expect(decoder.feedEpoch == decoder.stateSnapshotForTesting.feedEpoch) - // MARK: - Flush and close + // Double close is safe and idempotent. + decoder.close() + #expect(decoder.stateSnapshotForTesting.isClosed) + } - @Test("nothing composed before a flush is delivered after the flush returns", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func flushRetiresOutstandingFrames() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } + @Test("decode and flush after close are no-ops; double close is safe") + func closeRefusesDecodeAndFlushAndDoubleClose() async throws { + let primary = CountingFakeDecoder() + let enhancement = CountingFakeDecoder() + let decoder = DualLayerVideoDecoder( + composer: ImmediateFakeComposer(), + primaryDecoder: primary, + enhancementDecoder: enhancement + ) + let deliveries = Deliveries() + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) - for i in 0..<6 { + let epochBeforeClose = decoder.feedEpoch + decoder.close() + let snap = decoder.stateSnapshotForTesting + #expect(snap.isClosed) + #expect(snap.blQueueCount == 0 && snap.elQueueCount == 0) + #expect(snap.rpuCacheCount == 0 && snap.dmRpuCacheCount == 0) + #expect(snap.deliveryPendingCount == 0) + #expect(decoder.feedEpoch == epochBeforeClose + 1) + + // decode-after-close early-returns before touching the packet payload: + // the entry gate refuses without forwarding to either sub-decoder. + let packet = try #require(av_packet_alloc()) + defer { var p: UnsafeMutablePointer? = packet; av_packet_free(&p) } + decoder.decode(packet: packet) + decoder.decode(packet: packet, epoch: decoder.feedEpoch) + #expect(primary.decodeCount == 0) + #expect(enhancement.decodeCount == 0) + #expect(!decoder.stateSnapshotForTesting.enhancementEngaged) + + // flush-after-close is a no-op: still closed, epoch untouched, silent. + decoder.flush() + #expect(decoder.stateSnapshotForTesting.isClosed) + #expect(decoder.feedEpoch == epochBeforeClose + 1) + #expect(primary.flushCount == 0) + #expect(enhancement.flushCount == 0) + + // Post-close frame handlers and in-flight deliveries stay silent and + // issue no tickets. + let ticketsBefore = decoder.stateSnapshotForTesting.deliveryNextTicket let bl = try #require(Self.baseLayer()) let el = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) - } - decoder.flush() - deliveries.setMarker() - - // The decoder is usable after a flush: a fresh single-layer frame goes straight out. - decoder.primeForTesting(enhancementLayer: false) - let after = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: after, pts: Self.pts(100), hdr10Data: nil) - decoder.quiesceForTesting() - - let late = deliveries.snapshot.filter { $0.afterMarker } - #expect(late.count == 1) - #expect(late.first?.buffer === after) - } + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(0), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(0)) + decoder.quiesceForTesting() + #expect(deliveries.count == 0) + #expect(decoder.stateSnapshotForTesting.deliveryNextTicket == ticketsBefore) + + // Double close is safe and idempotent. + decoder.close() + #expect(decoder.stateSnapshotForTesting.isClosed) + #expect(primary.closeCount >= 1) + decoder.quiesceForTesting() + #expect(deliveries.count == 0) + } + + @Test("frames emitted from inside flush() are dropped and never delivered") + func flushDropsFramesEmittedFromInsideFlush() async throws { + // Synchronous composer, so any composition a drained frame managed to + // start would retire — and deliver — on the calling thread. If nothing + // arrives, the draining gate held on every path. + let primary = FlushEmittingFakeDecoder() + let enhancement = FlushEmittingFakeDecoder() + let decoder = DualLayerVideoDecoder( + composer: ImmediateFakeComposer(), + primaryDecoder: primary, + enhancementDecoder: enhancement + ) + let deliveries = Deliveries() + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + defer { decoder.close() } + + let drainBL0 = try #require(Self.baseLayer()) + let drainEL0 = try #require(Self.enhancementLayer()) + let drainBL2 = try #require(Self.baseLayer()) + let drainEL2 = try #require(Self.enhancementLayer()) + let pre0 = SendableFrameBuffer(value: drainBL0) + let pre0e = SendableFrameBuffer(value: drainEL0) + let pre2 = SendableFrameBuffer(value: drainBL2) + let pre2e = SendableFrameBuffer(value: drainEL2) + decoder.seedRPUForTesting(pts: Self.pts(0), params: Self.params(), displayMetadataNAL: nil) + decoder.seedRPUForTesting(pts: Self.pts(2), params: Self.params(), displayMetadataNAL: nil) + // What the installed `onFrame` closures would do with owed pre-seek + // frames: re-enter the pairing handlers from inside the flush, in both + // arrival orders (primary drains first, then enhancement). + primary.emitFromInsideFlush = { + decoder.handleBaseLayerFrame(baseBuffer: pre0.value, pts: Self.pts(0), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: pre0e.value, pts: Self.pts(0)) + } + enhancement.emitFromInsideFlush = { + decoder.handleEnhancementLayerFrame(elBuffer: pre2e.value, pts: Self.pts(2)) + decoder.handleBaseLayerFrame(baseBuffer: pre2.value, pts: Self.pts(2), hdr10Data: nil) + } - @Test("nothing is delivered after close returns", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func closeStopsDelivery() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) + decoder.flush() + decoder.quiesceForTesting() + #expect(deliveries.count == 0, "pre-seek frames drained during flush() must never be delivered") - for i in 0..<6 { + // The decoder is usable after the flush: a fresh pair composes and delivers. let bl = try #require(Self.baseLayer()) let el = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(Int64(i))) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) - } - decoder.close() - deliveries.setMarker() - decoder.quiesceForTesting() - - #expect(deliveries.snapshot.allSatisfy { !$0.afterMarker }) - } + decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) + // Even a synchronous composition's delivery is one more + // `queue.async` hop through `OrderedDelivery` away, so + // `handleBaseLayerFrame` returning is not a guarantee `onFrame` has + // run yet for it. + try await waitFor { deliveries.count == 1 } + #expect(deliveries.count == 1) + #expect(CMTimeCompare(deliveries.snapshot.first?.pts ?? .invalid, Self.pts(1)) == 0) + } + + @Test("the end-of-stream drain delivers the frames the sessions still owe, paired or alone, in order") + func endOfStreamDrainDeliversOwedFrames() async throws { + let primary = FlushEmittingFakeDecoder() + let enhancement = FlushEmittingFakeDecoder() + let decoder = DualLayerVideoDecoder( + composer: ImmediateFakeComposer(), + primaryDecoder: primary, + enhancementDecoder: enhancement + ) + let deliveries = Deliveries() + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + defer { decoder.close() } + + let bl: [SendableFrameBuffer] = try (0..<3).map { _ in + guard let b = Self.baseLayer() else { throw CancellationError() } + return SendableFrameBuffer(value: b) + } + let el: [SendableFrameBuffer] = try (0..<2).map { _ in + guard let e = Self.enhancementLayer() else { throw CancellationError() } + return SendableFrameBuffer(value: e) + } + for i in 0..<3 { + decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nil) + } + // The last frames of a stream, still inside the sessions after the last packet was fed: three + // base-layer frames, and enhancement-layer frames for the first two only. + primary.emitFromInsideFlush = { + for i in 0..<3 { + decoder.handleBaseLayerFrame(baseBuffer: bl[i].value, pts: Self.pts(Int64(i)), hdr10Data: nil) + } + } + enhancement.emitFromInsideFlush = { + for i in 0..<2 { + decoder.handleEnhancementLayerFrame(elBuffer: el[i].value, pts: Self.pts(Int64(i))) + } + } - @Test("close resets stream state and the decoder refuses to reopen") - func closeResetsStreamStateAndRefusesReopen() async throws { - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder() - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - - // Latch the MEL state a reopened instance must never inherit. - decoder.primeStreamForTesting(.mel) - #expect(decoder.streamClassificationForTesting == .mel) - - decoder.close() - - let snap = decoder.stateSnapshotForTesting - #expect(snap.isClosed) - #expect(!snap.isFEL && !snap.isMEL && !snap.streamClassified) - #expect(!snap.primaryDecoderInitialized) - #expect(!snap.enhancementEngaged) - #expect(snap.frameIndex == 0) - #expect(snap.blQueueCount == 0) - #expect(snap.elQueueCount == 0) - #expect(snap.rpuCacheCount == 0) - #expect(snap.dmRpuCacheCount == 0) - #expect(snap.deliveryPendingCount == 0) - #expect(snap.deliveryNextTicket == 0) - #expect(snap.deliveryNextToEmit == 0) - // Additional fields made explicit in snapshot (cheap to check): - #expect(!snap.draining) - #expect(snap.timeBaseNum == 1 && snap.timeBaseDen == 90000) - #expect(!snap.hasBLVPS && !snap.hasBLSPS && !snap.hasBLPPS) - #expect(!snap.hasELVPS && !snap.hasELSPS && !snap.hasELPPS) - #expect(!snap.hasSkipUntilPTS) - #expect(!snap.hasOnFrame) - - // Single use: `open` after `close` refuses. `open` checks the flag - // before touching `stream`, so uninitialized storage suffices here. - let raw = UnsafeMutableRawPointer.allocate( - byteCount: MemoryLayout.size, - alignment: MemoryLayout.alignment) - defer { raw.deallocate() } - let stream = raw.assumingMemoryBound(to: AVStream.self) - do { - try decoder.open(stream: stream) { _, _, _ in } - Issue.record("open after close must refuse with VideoDecoderError.decoderClosed") - } catch VideoDecoderError.decoderClosed { - // Expected: single-use decoder. - } - - // A post-close base-layer frame must not reach the cleared `onFrame`. - // `isClosed` gates `handleBaseLayerFrame` before any ticket is issued; - // settling before the negative check makes that hold by construction, - // as in every other post-teardown check in this file. - let bl = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(0), hdr10Data: nil) - decoder.quiesceForTesting() - #expect(deliveries.count == 0) - #expect(decoder.stateSnapshotForTesting.deliveryPendingCount == 0) - - // decode-after-close early-returns before touching the packet payload: - // the entry gate refuses without forwarding to either sub-decoder. - let packet = try #require(av_packet_alloc()) - defer { var p: UnsafeMutablePointer? = packet; av_packet_free(&p) } - decoder.decode(packet: packet) - decoder.decode(packet: packet, epoch: decoder.feedEpoch) - // No sub-decoders to check here (real decoders), but entry gate blocks. - - // flush-after-close is a no-op: still closed, epoch untouched, silent. - decoder.flush() - #expect(decoder.stateSnapshotForTesting.isClosed) - #expect(decoder.feedEpoch == decoder.stateSnapshotForTesting.feedEpoch) - - // Double close is safe and idempotent. - decoder.close() - #expect(decoder.stateSnapshotForTesting.isClosed) - } + decoder.drainForEndOfStream() + // No wait: the drain returns once its frames have reached `onFrame`. + let frames = deliveries.snapshot + #expect(frames.count == 3) + #expect(frames.map { ObjectIdentifier($0.buffer) } == bl.map { ObjectIdentifier($0.value) }) + #expect(Self.isStrictlyIncreasing(frames)) + #expect(decoder.composedFrameCount == 2, "the two frames with an enhancement layer are composed") + #expect(decoder.stateSnapshotForTesting.blQueueCount == 0) + + // The host's flush that follows has nothing left to drop or deliver. + primary.emitFromInsideFlush = nil + enhancement.emitFromInsideFlush = nil + decoder.flush() + decoder.quiesceForTesting() + #expect(deliveries.count == 3) + } - @Test("decode and flush after close are no-ops; double close is safe") - func closeRefusesDecodeAndFlushAndDoubleClose() async throws { - let primary = CountingFakeDecoder() - let enhancement = CountingFakeDecoder() - let decoder = DualLayerVideoDecoder( - composer: ImmediateFakeComposer(), - primaryDecoder: primary, - enhancementDecoder: enhancement - ) - let deliveries = Deliveries() - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - - let epochBeforeClose = decoder.feedEpoch - decoder.close() - let snap = decoder.stateSnapshotForTesting - #expect(snap.isClosed) - #expect(snap.blQueueCount == 0 && snap.elQueueCount == 0) - #expect(snap.rpuCacheCount == 0 && snap.dmRpuCacheCount == 0) - #expect(snap.deliveryPendingCount == 0) - #expect(decoder.feedEpoch == epochBeforeClose + 1) - - // decode-after-close early-returns before touching the packet payload: - // the entry gate refuses without forwarding to either sub-decoder. - let packet = try #require(av_packet_alloc()) - defer { var p: UnsafeMutablePointer? = packet; av_packet_free(&p) } - decoder.decode(packet: packet) - decoder.decode(packet: packet, epoch: decoder.feedEpoch) - #expect(primary.decodeCount == 0) - #expect(enhancement.decodeCount == 0) - #expect(!decoder.stateSnapshotForTesting.enhancementEngaged) - - // flush-after-close is a no-op: still closed, epoch untouched, silent. - decoder.flush() - #expect(decoder.stateSnapshotForTesting.isClosed) - #expect(decoder.feedEpoch == epochBeforeClose + 1) - #expect(primary.flushCount == 0) - #expect(enhancement.flushCount == 0) - - // Post-close frame handlers and in-flight deliveries stay silent and - // issue no tickets. - let ticketsBefore = decoder.stateSnapshotForTesting.deliveryNextTicket - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(0), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(0)) - decoder.quiesceForTesting() - #expect(deliveries.count == 0) - #expect(decoder.stateSnapshotForTesting.deliveryNextTicket == ticketsBefore) - - // Double close is safe and idempotent. - decoder.close() - #expect(decoder.stateSnapshotForTesting.isClosed) - #expect(primary.closeCount >= 1) - decoder.quiesceForTesting() - #expect(deliveries.count == 0) - } + // MARK: - Session loss (AE#739) - @Test("frames emitted from inside flush() are dropped and never delivered") - func flushDropsFramesEmittedFromInsideFlush() async throws { - // Synchronous composer, so any composition a drained frame managed to - // start would retire — and deliver — on the calling thread. If nothing - // arrives, the draining gate held on every path. - let primary = FlushEmittingFakeDecoder() - let enhancement = FlushEmittingFakeDecoder() - let decoder = DualLayerVideoDecoder( - composer: ImmediateFakeComposer(), - primaryDecoder: primary, - enhancementDecoder: enhancement - ) - let deliveries = Deliveries() - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - defer { decoder.close() } - - let drainBL0 = try #require(Self.baseLayer()) - let drainEL0 = try #require(Self.enhancementLayer()) - let drainBL2 = try #require(Self.baseLayer()) - let drainEL2 = try #require(Self.enhancementLayer()) - let pre0 = SendableFrameBuffer(value: drainBL0) - let pre0e = SendableFrameBuffer(value: drainEL0) - let pre2 = SendableFrameBuffer(value: drainBL2) - let pre2e = SendableFrameBuffer(value: drainEL2) - decoder.seedRPUForTesting(pts: Self.pts(0), params: Self.params(), displayMetadataNAL: nil) - decoder.seedRPUForTesting(pts: Self.pts(2), params: Self.params(), displayMetadataNAL: nil) - // What the installed `onFrame` closures would do with owed pre-seek - // frames: re-enter the pairing handlers from inside the flush, in both - // arrival orders (primary drains first, then enhancement). - primary.emitFromInsideFlush = { - decoder.handleBaseLayerFrame(baseBuffer: pre0.value, pts: Self.pts(0), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: pre0e.value, pts: Self.pts(0)) - } - enhancement.emitFromInsideFlush = { - decoder.handleEnhancementLayerFrame(elBuffer: pre2e.value, pts: Self.pts(2)) - decoder.handleBaseLayerFrame(baseBuffer: pre2.value, pts: Self.pts(2), hdr10Data: nil) - } - - decoder.flush() - decoder.quiesceForTesting() - #expect(deliveries.count == 0, "pre-seek frames drained during flush() must never be delivered") - - // The decoder is usable after the flush: a fresh pair composes and delivers. - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) - // Even a synchronous composition's delivery is one more - // `queue.async` hop through `OrderedDelivery` away, so - // `handleBaseLayerFrame` returning is not a guarantee `onFrame` has - // run yet for it. - try await waitFor { deliveries.count == 1 } - #expect(deliveries.count == 1) - #expect(CMTimeCompare(deliveries.snapshot.first?.pts ?? .invalid, Self.pts(1)) == 0) - } + @Test("the session-loss handler reaches both layers' decoders, once per loss, and clearing it clears both") + func sessionLostHandlerReachesBothLayers() { + let primary = HardwareVideoDecoder() + let enhancement = HardwareVideoDecoder() + let decoder = DualLayerVideoDecoder( + composer: ImmediateFakeComposer(), + primaryDecoder: primary, + enhancementDecoder: enhancement + ) + defer { decoder.close() } + let calls = SessionLossCount() + decoder.onSessionLost = { calls.bump() } + // A screen lock loses both sessions and each layer reports it: one recovery, not two. + primary.onSessionLost?() + enhancement.onSessionLost?() + #expect(calls.value == 1) + // After a rebuild (refused here: neither decoder was opened) the next loss is reported again. + _ = decoder.rebuildSession() + enhancement.onSessionLost?() + #expect(calls.value == 2) + decoder.onSessionLost = nil + #expect(primary.onSessionLost == nil) + #expect(enhancement.onSessionLost == nil) + } + + @Test("a closed decoder refuses a session rebuild") + func closedDecoderRefusesRebuild() { + let decoder = DualLayerVideoDecoder( + composer: ImmediateFakeComposer(), + primaryDecoder: FlushEmittingFakeDecoder(), + enhancementDecoder: FlushEmittingFakeDecoder() + ) + #expect(decoder.rebuildSession()) + decoder.close() + #expect(!decoder.rebuildSession()) + } - @Test("the end-of-stream drain delivers the frames the sessions still owe, paired or alone, in order") - func endOfStreamDrainDeliversOwedFrames() async throws { - let primary = FlushEmittingFakeDecoder() - let enhancement = FlushEmittingFakeDecoder() - let decoder = DualLayerVideoDecoder( - composer: ImmediateFakeComposer(), - primaryDecoder: primary, - enhancementDecoder: enhancement - ) - let deliveries = Deliveries() - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - defer { decoder.close() } - - let bl: [SendableFrameBuffer] = try (0..<3).map { _ in - guard let b = Self.baseLayer() else { throw CancellationError() } - return SendableFrameBuffer(value: b) - } - let el: [SendableFrameBuffer] = try (0..<2).map { _ in - guard let e = Self.enhancementLayer() else { throw CancellationError() } - return SendableFrameBuffer(value: e) - } - for i in 0..<3 { - decoder.seedRPUForTesting(pts: Self.pts(Int64(i)), params: Self.params(), displayMetadataNAL: nil) - } - // The last frames of a stream, still inside the sessions after the last packet was fed: three - // base-layer frames, and enhancement-layer frames for the first two only. - primary.emitFromInsideFlush = { - for i in 0..<3 { - decoder.handleBaseLayerFrame(baseBuffer: bl[i].value, pts: Self.pts(Int64(i)), hdr10Data: nil) + @Test("after a session rebuild both layers are back on the decoder's epoch, so later packets are accepted") + func rebuildAdoptsFeedEpoch() { + let primary = EpochRecordingFakeDecoder() + let enhancement = EpochRecordingFakeDecoder() + let decoder = DualLayerVideoDecoder( + composer: ImmediateFakeComposer(), + primaryDecoder: primary, + enhancementDecoder: enhancement + ) + defer { decoder.close() } + decoder.flush() + let epoch = decoder.feedEpoch + #expect(epoch > 0) + // A rebuilt hardware decoder moves its own epoch; the dual decoder must put it back. + primary.epoch = epoch &+ 7 + enhancement.epoch = epoch &+ 9 + #expect(decoder.rebuildSession()) + #expect(primary.epoch == epoch) + #expect(enhancement.epoch == epoch) + #expect(decoder.feedEpoch == epoch) + } + + @Test("the host's drain reaches the dual-layer decoder through the pipeline protocol; a plain decoder's is a no-op") + func drainDispatchesThroughProtocol() { + let primary = FlushEmittingFakeDecoder() + let decoder = DualLayerVideoDecoder( + composer: ImmediateFakeComposer(), + primaryDecoder: primary, + enhancementDecoder: nil + ) + defer { decoder.close() } + var flushedByDrain = 0 + primary.emitFromInsideFlush = { flushedByDrain += 1 } + let pipeline: any VideoDecodingPipeline = decoder + pipeline.drainForEndOfStream() + #expect(flushedByDrain == 1) + // A decoder whose flush delivers needs no drain. + let plain: any VideoDecodingPipeline = FlushEmittingFakeDecoder() + plain.drainForEndOfStream() + } + + private final class EpochRecordingFakeDecoder: SingleLayerVideoDecoder, @unchecked Sendable { + var onFrame: DecodedFrameHandler? + var onFirstHDR10PlusDetected: (@Sendable () -> Void)? + var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? + var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? + var skipUntilPTS: CMTime? + var epoch: UInt64 = 0 + var feedEpoch: UInt64 { epoch } + func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws { + self.onFrame = onFrame } - } - enhancement.emitFromInsideFlush = { - for i in 0..<2 { - decoder.handleEnhancementLayerFrame(elBuffer: el[i].value, pts: Self.pts(Int64(i))) + func open(parameterSets: [[UInt8]], timeBase: AVRational, onFrame: @escaping DecodedFrameHandler) throws { + self.onFrame = onFrame + } + func decode(packet: UnsafeMutablePointer, epoch: UInt64?) {} + func flush() {} + func adoptFeedEpoch(_ epoch: UInt64) { self.epoch = epoch } + func close() { onFrame = nil } + } + + private final class SessionLossCount: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + func bump() { lock.lock(); count += 1; lock.unlock() } + var value: Int { lock.lock(); defer { lock.unlock() }; return count } + } + + // MARK: - Thread safety + + /// Synchronous composer so the stress test needs no GPU: every composition + /// retires on the calling thread instead of on a Metal completion queue. + /// + /// Delivery is still asynchronous: every frame, composed or bypassed, is + /// one more `queue.async` hop away from `onFrame` regardless of how this + /// fake calls its `completion`. What this fake buys a test: pairing and composition finish deterministically, in program + /// order, with no GPU and no wait for a command buffer to retire, so a + /// test can reason about ticket order and queue depth without also + /// reasoning about the exactly-once assertions being asynchronous; + /// tests below still have to wait for the frame to actually leave + /// `OrderedDelivery`'s own queue (`waitFor`/`settle`). + private final class ImmediateFakeComposer: FelComposing, @unchecked Sendable { + func acquireSlot() {} + func releaseSlot() {} + func flush() {} + func composeAsync( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void + ) { + completion(baseBuffer) + } + func composeAsyncWithAcquiredSlot( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void + ) { + completion(baseBuffer) } } - decoder.drainForEndOfStream() - // No wait: the drain returns once its frames have reached `onFrame`. - let frames = deliveries.snapshot - #expect(frames.count == 3) - #expect(frames.map { ObjectIdentifier($0.buffer) } == bl.map { ObjectIdentifier($0.value) }) - #expect(Self.isStrictlyIncreasing(frames)) - #expect(decoder.composedFrameCount == 2, "the two frames with an enhancement layer are composed") - #expect(decoder.stateSnapshotForTesting.blQueueCount == 0) - - // The host's flush that follows has nothing left to drop or deliver. - primary.emitFromInsideFlush = nil - enhancement.emitFromInsideFlush = nil - decoder.flush() - decoder.quiesceForTesting() - #expect(deliveries.count == 3) - } - - // MARK: - Session loss (AE#739) - - @Test("the session-loss handler reaches both layers' decoders, once per loss, and clearing it clears both") - func sessionLostHandlerReachesBothLayers() { - let primary = HardwareVideoDecoder() - let enhancement = HardwareVideoDecoder() - let decoder = DualLayerVideoDecoder( - composer: ImmediateFakeComposer(), - primaryDecoder: primary, - enhancementDecoder: enhancement - ) - defer { decoder.close() } - let calls = SessionLossCount() - decoder.onSessionLost = { calls.bump() } - // A screen lock loses both sessions and each layer reports it: one recovery, not two. - primary.onSessionLost?() - enhancement.onSessionLost?() - #expect(calls.value == 1) - // After a rebuild (refused here: neither decoder was opened) the next loss is reported again. - _ = decoder.rebuildSession() - enhancement.onSessionLost?() - #expect(calls.value == 2) - decoder.onSessionLost = nil - #expect(primary.onSessionLost == nil) - #expect(enhancement.onSessionLost == nil) - } + private struct SendableFrameBuffer: @unchecked Sendable { + let value: CVPixelBuffer + } + + /// Fake single-layer decoder: runs a hook from inside `flush()`, + /// the way `HardwareVideoDecoder.flush()` re-enters the frame callbacks + /// with the frames VideoToolbox still owes while the host thread is + /// blocked inside the flush. + private final class FlushEmittingFakeDecoder: SingleLayerVideoDecoder, @unchecked Sendable { + var onFrame: DecodedFrameHandler? + var onFirstHDR10PlusDetected: (@Sendable () -> Void)? + var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? + var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? + var skipUntilPTS: CMTime? + var feedEpoch: UInt64 = 0 + var emitFromInsideFlush: (() -> Void)? + func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws { + self.onFrame = onFrame + } + func open( + parameterSets: [[UInt8]], + timeBase: AVRational, + onFrame: @escaping DecodedFrameHandler + ) throws { + self.onFrame = onFrame + } + func decode(packet: UnsafeMutablePointer, epoch: UInt64?) {} + func flush() { emitFromInsideFlush?() } + func close() { onFrame = nil } + } + + /// Counting fake: records sub-decoder traffic so tests can prove + /// post-close `decode`/`flush` never reach the torn-down sessions. + private final class CountingFakeDecoder: SingleLayerVideoDecoder, @unchecked Sendable { + private let lock = NSLock() + private var _decodeCount = 0 + private var _flushCount = 0 + private var _closeCount = 0 + var decodeCount: Int { lock.lock(); defer { lock.unlock() }; return _decodeCount } + var flushCount: Int { lock.lock(); defer { lock.unlock() }; return _flushCount } + var closeCount: Int { lock.lock(); defer { lock.unlock() }; return _closeCount } + var onFrame: DecodedFrameHandler? + var onFirstHDR10PlusDetected: (@Sendable () -> Void)? + var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? + var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? + var skipUntilPTS: CMTime? + var feedEpoch: UInt64 = 0 + func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws { + self.onFrame = onFrame + } + func open( + parameterSets: [[UInt8]], + timeBase: AVRational, + onFrame: @escaping DecodedFrameHandler + ) throws { + self.onFrame = onFrame + } + func decode(packet: UnsafeMutablePointer, epoch: UInt64?) { + lock.lock(); _decodeCount += 1; lock.unlock() + } + func flush() { lock.lock(); _flushCount += 1; lock.unlock() } + func close() { lock.lock(); _closeCount += 1; lock.unlock(); onFrame = nil } + } + + @Test("concurrent layer arrivals and control-plane churn keep exactly-once delivery; close under load stays silent") + func concurrentArrivalsAndTeardown() async throws { + let frames = 64 + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + for i in 0.. = [] + do { + // Nudged unconditionally, so the test does not have to decide by a timeout whether + // frames are stranded. `collectDueBypasses` ages a queued base-layer frame out after + // `K` further base-layer *arrivals*, not elapsed time, and only runs from inside + // `handleBaseLayerFrame`. This test's 64-way unthrottled fan-out delivers each layer + // out of decode order essentially every run (measured: 2–3 of 64 base-layer frames + // stranded in `blFrameQueue` in 40 of 40 runs; two threads delivering each layer in + // decode order stranded 0 of 40). That cannot happen mid-stream in production, where + // VideoToolbox outputs each layer in decode order; the only production exposure is + // the last base-layer frames at end of stream whose enhancement layer never arrives, + // which is the K-frame bound itself. A base layer can be scheduled so late that + // "newest settled base layer" has already raced ahead of it; its enhancement layer is + // then correctly dropped as stale, and no further base-layer arrival happens within + // this batch to age it out before the `DispatchGroup` above finishes. It is still + // queued, not lost: a few harmless, never-pairing nudges (fresh buffers, PTS values + // outside this test's range) advance `blArrivalSeq` past it. + for i in 0..<(DualLayerVideoDecoder.maxLayerSkewFrames + 1) { + guard let nudge = Self.baseLayer() else { break } + nudgeIDs.insert(ObjectIdentifier(nudge)) + decoder.handleBaseLayerFrame(baseBuffer: nudge, pts: Self.pts(Int64(900 + i)), hdr10Data: nil) + } + try await waitFor { + deliveries.snapshot.filter { sentIDs.contains(ObjectIdentifier($0.buffer)) }.count >= frames + } + } + // `allSatisfy`, not a `filter`: an enhancement-layer buffer (or + // anything else) delivered on its own is what this catches; sent + // base-layer frames and the nudges above are the only buffers ever + // allowed out. + let allowed = sentIDs.union(nudgeIDs) + #expect(deliveries.snapshot.allSatisfy { allowed.contains(ObjectIdentifier($0.buffer)) }, + "only sent base-layer frames or the nudges that age out stranded ones may ever be delivered") + let got = deliveries.snapshot.filter { sentIDs.contains(ObjectIdentifier($0.buffer)) } + #expect(got.count == frames) + #expect(Set(got.map { ObjectIdentifier($0.buffer) }).count == frames) + + // Teardown under load: arrivals racing close() are either delivered + // before it or dropped after it; nothing lands past the marker. + let teardown = DispatchGroup() + for i in frames..<(frames + 8) { + teardown.enter() + DispatchQueue.global().async { + if let b = Self.baseLayer(), let e = Self.enhancementLayer() { + decoder.handleBaseLayerFrame(baseBuffer: b, pts: Self.pts(Int64(i)), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: e, pts: Self.pts(Int64(i))) + } + teardown.leave() + } + } + teardown.enter() + DispatchQueue.global().async { + decoder.flush() + decoder.close() + teardown.leave() + } + await withCheckedContinuation { cont in teardown.notify(queue: .global()) { cont.resume() } } + deliveries.setMarker() + if let b = Self.baseLayer(), let e = Self.enhancementLayer() { + decoder.handleBaseLayerFrame(baseBuffer: b, pts: Self.pts(10_000), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: e, pts: Self.pts(10_000)) + } + decoder.quiesceForTesting() + #expect(deliveries.snapshot.allSatisfy { !$0.afterMarker }) + } + + // MARK: - Delivery queue + + /// Fake composer whose every composition fails (completes with nil), as a GPU error does. + private final class FailingFakeComposer: FelComposing, @unchecked Sendable { + func acquireSlot() {} + func releaseSlot() {} + func flush() {} + func composeAsync( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void + ) { + completion(nil) + } + func composeAsyncWithAcquiredSlot( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void + ) { + completion(nil) + } + } - @Test("after a session rebuild both layers are back on the decoder's epoch, so later packets are accepted") - func rebuildAdoptsFeedEpoch() { - let primary = EpochRecordingFakeDecoder() - let enhancement = EpochRecordingFakeDecoder() - let decoder = DualLayerVideoDecoder( - composer: ImmediateFakeComposer(), - primaryDecoder: primary, - enhancementDecoder: enhancement - ) - defer { decoder.close() } - decoder.flush() - let epoch = decoder.feedEpoch - #expect(epoch > 0) - // A rebuilt hardware decoder moves its own epoch; the dual decoder must put it back. - primary.epoch = epoch &+ 7 - enhancement.epoch = epoch &+ 9 - #expect(decoder.rebuildSession()) - #expect(primary.epoch == epoch) - #expect(enhancement.epoch == epoch) - #expect(decoder.feedEpoch == epoch) - } + /// Fake composer whose Nth call (1-indexed) never calls its completion at + /// all — the one failure mode a ticket cannot self-report, unlike a + /// bypass or a "compose failed" fallback. Every other call completes + /// synchronously with the base layer, like `ImmediateFakeComposer`. + private final class DroppingFakeComposer: FelComposing, @unchecked Sendable { + private let dropOnCall: Int + private let lock = NSLock() + private var calls = 0 + init(dropOnCall: Int) { self.dropOnCall = dropOnCall } + func acquireSlot() {} + func releaseSlot() {} + func flush() {} + func composeAsync( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void + ) { + composeAsyncWithAcquiredSlot(baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, params: params, frameIndex: frameIndex, completion: completion) + } + func composeAsyncWithAcquiredSlot( + baseBuffer: CVPixelBuffer, + enhancementBuffer: CVPixelBuffer, + params: DoviMetalParams, + frameIndex: UInt32, + completion: @escaping @Sendable (CVPixelBuffer?) -> Void + ) { + lock.lock(); calls += 1; let n = calls; lock.unlock() + if n == dropOnCall { + // Simulates a completion that never fires: no call to + // `completion`, so the ticket this composition took is never + // fulfilled at all. + return + } + completion(baseBuffer) + } + } - @Test("the host's drain reaches the dual-layer decoder through the pipeline protocol; a plain decoder's is a no-op") - func drainDispatchesThroughProtocol() { - let primary = FlushEmittingFakeDecoder() - let decoder = DualLayerVideoDecoder( - composer: ImmediateFakeComposer(), - primaryDecoder: primary, - enhancementDecoder: nil - ) - defer { decoder.close() } - var flushedByDrain = 0 - primary.emitFromInsideFlush = { flushedByDrain += 1 } - let pipeline: any VideoDecodingPipeline = decoder - pipeline.drainForEndOfStream() - #expect(flushedByDrain == 1) - // A decoder whose flush delivers needs no drain. - let plain: any VideoDecodingPipeline = FlushEmittingFakeDecoder() - plain.drainForEndOfStream() - } + @Test("a composition whose completion never fires costs exactly one frame, not the session") + func lostCompletionCostsExactlyOneFrame() async throws { + let threshold = DoviFelComposer.maxFramesInFlight + DualLayerVideoDecoder.maxLayerSkewFrames + 2 + let composer = DroppingFakeComposer(dropOnCall: 1) + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: composer) + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: true) + defer { decoder.close() } + + // Every frame pairs immediately (EL arrives first, so `handleBaseLayerFrame` + // composes on the spot) so ticket `i` and loop index `i` coincide. The + // first composition's ticket (0) is the one `DroppingFakeComposer` drops; + // enough frames follow to push it past `orderedDelivery`'s stall + // threshold (`maxFramesInFlight + K + 2`). + let frames = threshold + 6 + for i in 0.. Void)? - var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? - var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? - var skipUntilPTS: CMTime? - var epoch: UInt64 = 0 - var feedEpoch: UInt64 { epoch } - func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws { - self.onFrame = onFrame - } - func open(parameterSets: [[UInt8]], timeBase: AVRational, onFrame: @escaping DecodedFrameHandler) throws { - self.onFrame = onFrame - } - func decode(packet: UnsafeMutablePointer, epoch: UInt64?) {} - func flush() {} - func adoptFeedEpoch(_ epoch: UInt64) { self.epoch = epoch } - func close() { onFrame = nil } - } + try await waitFor { deliveries.count == frames - 1 } + let got = deliveries.snapshot + #expect(got.count == frames - 1, "every frame but the one lost ticket must still be delivered") + #expect(Self.isStrictlyIncreasing(got), "the stall guard must not disturb the order of the frames it does deliver") + } + + #if DEBUG + @Test("a ticket raced into the gap between flush()'s unlock and its delivery barrier is not lost") + func flushDoesNotLosePostFlushTicketInTheUnlockToBarrierGap() async throws { + // Regression test for an ordering race in `flush()`: drives a ticket + // through the window a post-flush ticket could be lost in — + // right after the critical section that bumps `_feedEpoch`, captures + // `nextTicket` and enqueues `orderedDelivery.resetAsync` releases + // `lock`, but before `orderedDelivery.barrier()` confirms that reset + // has actually run — using `flushRaceHookForTesting`/ + // `raceTicketForTesting` rather than waiting on a real, sub- + // microsecond production race. + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) + decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } + decoder.primeForTesting(enhancementLayer: false) + defer { + decoder.flushRaceHookForTesting = nil + decoder.close() + } - private final class SessionLossCount: @unchecked Sendable { - private let lock = NSLock() - private var count = 0 - func bump() { lock.lock(); count += 1; lock.unlock() } - var value: Int { lock.lock(); defer { lock.unlock() }; return count } - } + for i in 0..<5 { + let bl = try #require(Self.baseLayer()) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(Int64(i)), hdr10Data: nil) + } + try await waitFor { deliveries.count == 5 } - // MARK: - Thread safety - - /// Synchronous composer so the stress test needs no GPU: every composition - /// retires on the calling thread instead of on a Metal completion queue. - /// - /// Delivery is still asynchronous: every frame, composed or bypassed, is - /// one more `queue.async` hop away from `onFrame` regardless of how this - /// fake calls its `completion`. What this fake buys a test: pairing and composition finish deterministically, in program - /// order, with no GPU and no wait for a command buffer to retire, so a - /// test can reason about ticket order and queue depth without also - /// reasoning about the exactly-once assertions being asynchronous; - /// tests below still have to wait for the frame to actually leave - /// `OrderedDelivery`'s own queue (`waitFor`/`settle`). - private final class ImmediateFakeComposer: FelComposing, @unchecked Sendable { - func acquireSlot() {} - func releaseSlot() {} - func flush() {} - func composeAsync( - baseBuffer: CVPixelBuffer, - enhancementBuffer: CVPixelBuffer, - params: DoviMetalParams, - frameIndex: UInt32, - completion: @escaping @Sendable (CVPixelBuffer?) -> Void - ) { - completion(baseBuffer) - } - func composeAsyncWithAcquiredSlot( - baseBuffer: CVPixelBuffer, - enhancementBuffer: CVPixelBuffer, - params: DoviMetalParams, - frameIndex: UInt32, - completion: @escaping @Sendable (CVPixelBuffer?) -> Void - ) { - completion(baseBuffer) - } - } + let racer = try #require(Self.baseLayer()) + decoder.flushRaceHookForTesting = { [weak decoder] in + decoder?.raceTicketForTesting(buffer: racer, pts: Self.pts(5)) + } - private struct SendableFrameBuffer: @unchecked Sendable { - let value: CVPixelBuffer - } + decoder.flush() - /// Fake single-layer decoder: runs a hook from inside `flush()`, - /// the way `HardwareVideoDecoder.flush()` re-enters the frame callbacks - /// with the frames VideoToolbox still owes while the host thread is - /// blocked inside the flush. - private final class FlushEmittingFakeDecoder: SingleLayerVideoDecoder, @unchecked Sendable { - var onFrame: DecodedFrameHandler? - var onFirstHDR10PlusDetected: (@Sendable () -> Void)? - var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? - var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? - var skipUntilPTS: CMTime? - var feedEpoch: UInt64 = 0 - var emitFromInsideFlush: (() -> Void)? - func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws { - self.onFrame = onFrame - } - func open( - parameterSets: [[UInt8]], - timeBase: AVRational, - onFrame: @escaping DecodedFrameHandler - ) throws { - self.onFrame = onFrame - } - func decode(packet: UnsafeMutablePointer, epoch: UInt64?) {} - func flush() { emitFromInsideFlush?() } - func close() { onFrame = nil } - } + // The raced ticket must have been delivered exactly once — not + // dropped as stale-epoch, and not left stalled + // behind a hole that never fills. + try await waitFor { deliveries.count == 6 } + let got = deliveries.snapshot + let late = got.filter { CMTimeCompare($0.pts, Self.pts(5)) == 0 } + #expect(late.count == 1, "the ticket raced into the unlock-to-barrier gap must be delivered exactly once, not dropped") + #expect(Self.isStrictlyIncreasing(got)) + } + #endif + + /// A packet carrying in-band parameter sets and one (garbage) slice, as `decode(packet:)` sees it. + private static func inBandPacket(pts: Int64) throws -> UnsafeMutablePointer { + let slice = hevcNAL(type: 1, payload: [0x11, 0x22, 0x33, 0x44]) + return try #require(makeAvccPacket([hevcVPS, hevcSPS, hevcPPS, slice], pts: pts)) + } + + #if DEBUG + @Test("a post-seek packet fed with the new epoch between the dual epoch bump and the sub-decoder flush reaches VideoToolbox; a pre-seek one does not") + func postSeekPacketInTheFlushWindowIsNotDropped() async throws { + // The host feeds from the demux thread while a seek's flush() runs. It reads the new epoch the + // moment flush() publishes it. The sub-decoders used to move only in their own flush(), later, + // so a packet fed in this window passed the dual gate and was refused by a decoder still on + // the old epoch: the post-seek IRAP never reached VideoToolbox. + let primary = HardwareVideoDecoder() + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer(), primaryDecoder: primary, enhancementDecoder: nil) + decoder.onFrame = { _, _, _ in } + defer { decoder.flushWillTakeLifecycleLockHookForTesting = nil; decoder.flushRaceHookForTesting = nil; decoder.close() } + let submitted = LockedCounter() + primary.onSubmittedToVideoToolboxForTesting = { submitted.increment() } + + // The first packet opens the primary decoder from its in-band parameter sets. + let pre = try Self.inBandPacket(pts: 0) + defer { var p: UnsafeMutablePointer? = pre; trackedPacketFree(&p) } + decoder.decode(packet: pre, epoch: decoder.feedEpoch) + if decoder.fatalDecodeError != nil { try Test.cancel("hardware HEVC session unavailable: \(decoder.fatalDecodeError!)") } + #expect(submitted.value == 1) + let oldEpoch = decoder.feedEpoch + + let post = try Self.inBandPacket(pts: 90000) + defer { var p: UnsafeMutablePointer? = post; trackedPacketFree(&p) } + let submittedInWindow = LockedCounter() + decoder.flushRaceHookForTesting = { + // flush() has published the new epoch and not yet touched the sub-decoders. + decoder.decode(packet: post, epoch: decoder.feedEpoch) + submittedInWindow.set(submitted.value) + } + decoder.flush() + #expect(decoder.feedEpoch == oldEpoch + 1) + #expect(primary.feedEpoch == oldEpoch + 1, "the sub-decoder's own flush must not move the epoch a second time") + #expect(submittedInWindow.value == 2, "the post-seek packet fed inside the window never reached VideoToolbox") + + // DEC-4: a packet decided under the old epoch must not enter VideoToolbox after the flush. + decoder.decode(packet: pre, epoch: oldEpoch) + #expect(submitted.value == 2) + // And the epoch the host would read next is accepted. + decoder.decode(packet: post, epoch: decoder.feedEpoch) + #expect(submitted.value == 3) + } + #endif + + /// Fake whose in-band open always fails. + private final class FailingOpenFakeDecoder: SingleLayerVideoDecoder, @unchecked Sendable { + private let lock = NSLock() + private var _opens = 0, _decodes = 0 + var openCount: Int { lock.lock(); defer { lock.unlock() }; return _opens } + var decodeCount: Int { lock.lock(); defer { lock.unlock() }; return _decodes } + struct OpenFailed: Error {} + var onFrame: DecodedFrameHandler? + var onFirstHDR10PlusDetected: (@Sendable () -> Void)? + var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? + var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? + var skipUntilPTS: CMTime? + var feedEpoch: UInt64 = 0 + func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws {} + func open(parameterSets: [[UInt8]], timeBase: AVRational, onFrame: @escaping DecodedFrameHandler) throws { + lock.lock(); _opens += 1; lock.unlock() + throw OpenFailed() + } + func decode(packet: UnsafeMutablePointer, epoch: UInt64?) { lock.lock(); _decodes += 1; lock.unlock() } + func flush() {} + func close() {} + } + + @Test("an in-band primary open that fails is reported once and not retried on every later packet") + func failedInBandOpenFailsOnce() throws { + let primary = FailingOpenFakeDecoder() + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer(), primaryDecoder: primary, enhancementDecoder: nil) + defer { decoder.close() } + let reported = LockedCounter() + decoder.onFatalDecodeError = { _ in reported.increment() } + let lines = LockedCounter() + let previous = EngineLog.handler + EngineLog.handler = { line in if line.contains("failed to open the primary decoder") { lines.increment() } } + defer { EngineLog.handler = previous } + + for i in 0..<5 { + let pkt = try Self.inBandPacket(pts: Int64(i) * 3000) + decoder.decode(packet: pkt) + var p: UnsafeMutablePointer? = pkt + trackedPacketFree(&p) + } + #expect(primary.openCount == 1, "the open was retried on a later packet") + #expect(reported.value == 1) + #expect(lines.value == 1) + #expect(decoder.fatalDecodeError is FailingOpenFakeDecoder.OpenFailed) + #expect(primary.decodeCount == 0) + } + + @Test("an enhancement decoder that fails to open is logged once and not retried on every later packet") + func failedEnhancementOpenFailsOnce() throws { + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer(), primaryDecoder: FailingOpenFakeDecoder(), enhancementDecoder: nil) + let enhancement = FailingOpenFakeDecoder() + let built = LockedCounter() + decoder.makeEnhancementDecoder = { built.increment(); return enhancement } + defer { decoder.close() } + let lines = LockedCounter() + let previous = EngineLog.handler + EngineLog.handler = { line in if line.contains("failed to initialize EL decoder") { lines.increment() } } + defer { EngineLog.handler = previous } + + for i in 0..<5 { + let pkt = try Self.inBandPacket(pts: Int64(i) * 3000) + decoder.ensureEnhancementDecoder(from: pkt) + var p: UnsafeMutablePointer? = pkt + trackedPacketFree(&p) + } + #expect(enhancement.openCount == 1, "the open was retried on a later packet") + #expect(built.value == 1, "a new session was built for a later packet") + #expect(lines.value == 1) + #expect(decoder.enhancementDecoderOpenError is FailingOpenFakeDecoder.OpenFailed) + #expect(decoder.fatalDecodeError == nil, "a missing enhancement layer leaves the base layer to show") + // close() starts the next session clean. + decoder.close() + #expect(decoder.enhancementDecoderOpenError == nil) + } + + @Test("the alignment's restamp bound is what the pairing skew bound can absorb") + func alignmentBoundTracksSkewBound() { + #expect(DualLayerVideoDecoder.makeAlignment().maxBackwardRestamp == DualLayerVideoDecoder.maxLayerSkewFrames - 1) + } + + @Test("a crafted raw timestamp that overflows the time-base product yields no key instead of trapping") + func canonicalKeyIsOverflowSafe() { + let tb = AVRational(num: 1, den: 90000) + #expect(DualLayerVideoDecoder.canonicalKey(rawPTS: 90000, timeBase: tb) == 90000) + #expect(DualLayerVideoDecoder.canonicalKey(rawPTS: Int64.min, timeBase: tb) == Int64.min) + let wide = AVRational(num: 1_000_000, den: 1) + #expect(DualLayerVideoDecoder.canonicalKey(rawPTS: Int64.max, timeBase: wide) == Int64.min) + #expect(DualLayerVideoDecoder.canonicalKey(rawPTS: Int64.min + 1, timeBase: wide) == Int64.min) + // Large but representable products saturate in CoreMedia rather than trapping. + _ = DualLayerVideoDecoder.canonicalKey(rawPTS: Int64.max / 2, timeBase: AVRational(num: 2, den: 1)) + } + + @Test("a slow onFrame does not block pairing or flush() behind the decoder lock", + .timeLimit(.minutes(5))) + func slowOnFrameDoesNotBlockDecoderOperations() async throws { + // The old `deliver()` took the decoder's lock and called `emit` (hence + // `onFrame`) from inside it, so a slow host callback would have stalled + // every later frame handler and `flush()` behind the same lock. With + // `OrderedDelivery` owning `emit` on its own queue, pairing a new frame + // never waits on `onFrame` at all — but `flush()` is a different claim: + // its `orderedDelivery.barrier()` call legitimately waits for whatever + // `emit` is currently in the middle of before draining can proceed. The + // bound here is "as long as `onFrame` itself takes", not "instantly" + // and not "forever". + // `firstCallStarted` is polled rather than waited on with a semaphore: + // `DispatchSemaphore.wait` is unavailable from this `async` test body + // (it would block the cooperative thread pool), but it is exactly + // right *inside* the plain, non-async `onFrame` closure below, which + // runs on `OrderedDelivery`'s own queue and is what this test needs + // to hold still to prove pairing is not waiting on it. + let firstCallStarted = BoolFlag() + let releaseFirstCall = DispatchSemaphore(value: 0) + let callCount = LockedCounter() + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) + decoder.onFrame = { buffer, pts, hdr10 in + let isFirst = callCount.incrementAndCheckFirst() + if isFirst { + firstCallStarted.set() + releaseFirstCall.wait() + } + deliveries.record(buffer, pts, hdr10) + } + decoder.primeForTesting(enhancementLayer: false) + defer { + releaseFirstCall.signal() // in case an assertion below fails first + decoder.close() + } - /// Counting fake: records sub-decoder traffic so tests can prove - /// post-close `decode`/`flush` never reach the torn-down sessions. - private final class CountingFakeDecoder: SingleLayerVideoDecoder, @unchecked Sendable { - private let lock = NSLock() - private var _decodeCount = 0 - private var _flushCount = 0 - private var _closeCount = 0 - var decodeCount: Int { lock.lock(); defer { lock.unlock() }; return _decodeCount } - var flushCount: Int { lock.lock(); defer { lock.unlock() }; return _flushCount } - var closeCount: Int { lock.lock(); defer { lock.unlock() }; return _closeCount } - var onFrame: DecodedFrameHandler? - var onFirstHDR10PlusDetected: (@Sendable () -> Void)? - var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? - var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? - var skipUntilPTS: CMTime? - var feedEpoch: UInt64 = 0 - func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws { - self.onFrame = onFrame - } - func open( - parameterSets: [[UInt8]], - timeBase: AVRational, - onFrame: @escaping DecodedFrameHandler - ) throws { - self.onFrame = onFrame - } - func decode(packet: UnsafeMutablePointer, epoch: UInt64?) { - lock.lock(); _decodeCount += 1; lock.unlock() - } - func flush() { lock.lock(); _flushCount += 1; lock.unlock() } - func close() { lock.lock(); _closeCount += 1; lock.unlock(); onFrame = nil } - } + let bl0 = try #require(Self.baseLayer()) + decoder.handleBaseLayerFrame(baseBuffer: bl0, pts: Self.pts(0), hdr10Data: nil) + try await waitFor { firstCallStarted.isSet } + #expect(firstCallStarted.isSet, "onFrame for frame 0 never started") + + // While that first `onFrame` call is still parked, pairing a new frame + // must not wait on it at all: `handleBaseLayerFrame` only ever + // `fulfil`s a ticket onto `OrderedDelivery`'s queue (`queue.async`, + // non-blocking) and never calls into `emit`. The call returning at all, + // with the release semaphore still unsignalled, is the proof: if it + // waited on the parked `onFrame` it would never return and the test's + // time limit would report it. No wall-clock threshold is involved. + let bl1 = try #require(Self.baseLayer()) + decoder.handleBaseLayerFrame(baseBuffer: bl1, pts: Self.pts(1), hdr10Data: nil) + + // `flush()` legitimately waits for the in-flight `emit` (its `barrier()` + // waits on the queue `emit` runs on), so it runs on a thread of its own + // and the release is signalled from here. It must then finish; if the + // decoder's lock were held across the wait it would hang, and the time + // limit would say so. + let flushDone = BoolFlag() + Thread.detachNewThread { + decoder.flush() + flushDone.set() + } + releaseFirstCall.signal() + try await waitFor { flushDone.isSet } + #expect(flushDone.isSet, "flush() never returned after the slow onFrame call was released") + + decoder.quiesceForTesting() + } + + /// Thread-safe increment that also reports whether this call was the + /// first one, without a data race between the check and the increment. + private final class LockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + func incrementAndCheckFirst() -> Bool { + lock.lock(); defer { lock.unlock() } + count += 1 + return count == 1 + } + func increment() { lock.lock(); count += 1; lock.unlock() } + func set(_ v: Int) { lock.lock(); count = v; lock.unlock() } + var value: Int { lock.lock(); defer { lock.unlock() }; return count } + } + + /// A boolean settable from one thread and polled from another, with no + /// torn reads. Used both as a "stop requested" flag and, in + /// `slowOnFrameDoesNotBlockDecoderOperations`, as a "started" flag — the + /// name is generic on purpose. + private final class BoolFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + func set() { lock.lock(); value = true; lock.unlock() } + var isSet: Bool { lock.lock(); defer { lock.unlock() }; return value } + } + + /// Lock-protected "flushes started"/"flushes returned" counters for the + /// per-flush I3 detector in `flushFromAnotherThreadInATightLoopKeepsInvariantI3`. + private final class FlushCounters: @unchecked Sendable { + private let lock = NSLock() + private var started = 0 + private var returned = 0 + /// Call immediately before `decoder.flush()`. Returns the new count. + @discardableResult + func beginFlush() -> Int { lock.lock(); started += 1; defer { lock.unlock() }; return started } + /// Call immediately after `decoder.flush()` returns. + func endFlush() { lock.lock(); returned += 1; lock.unlock() } + var startedCount: Int { lock.lock(); defer { lock.unlock() }; return started } + var returnedCount: Int { lock.lock(); defer { lock.unlock() }; return returned } + } + + /// Records each frame's "started" tag (keyed by its unique PTS value) and + /// any I3 violation the detector in + /// `flushFromAnotherThreadInATightLoopKeepsInvariantI3` finds — a class, + /// like `Deliveries`/`FlushCounters` above, so the producer task and the + /// `onFrame` closure can share it as a `let` capture rather than a `var` + /// one, which Swift 6 strict concurrency refuses to let a closure and a + /// detached task mutate directly. + private final class ViolationTracker: @unchecked Sendable { + private let lock = NSLock() + private var startedTagByPTS: [Int64: Int] = [:] + private var violations: [String] = [] + + /// Marks a frame as being inside its pairing call. Delivery is asynchronous and can run before + /// the call returns, so the frame has to be known to the detector before the call starts; the + /// real tag replaces this once the call returns. + func registerInFlight(ptsValue: Int64) { + lock.lock(); startedTagByPTS[ptsValue] = Self.inFlight; lock.unlock() + } - @Test("concurrent layer arrivals and control-plane churn keep exactly-once delivery; close under load stays silent") - func concurrentArrivalsAndTeardown() async throws { - let frames = 64 - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - for i in 0.. = [] - do { - // Nudged unconditionally, so the test does not have to decide by a timeout whether - // frames are stranded. `collectDueBypasses` ages a queued base-layer frame out after - // `K` further base-layer *arrivals*, not elapsed time, and only runs from inside - // `handleBaseLayerFrame`. This test's 64-way unthrottled fan-out delivers each layer - // out of decode order essentially every run (measured: 2–3 of 64 base-layer frames - // stranded in `blFrameQueue` in 40 of 40 runs; two threads delivering each layer in - // decode order stranded 0 of 40). That cannot happen mid-stream in production, where - // VideoToolbox outputs each layer in decode order; the only production exposure is - // the last base-layer frames at end of stream whose enhancement layer never arrives, - // which is the K-frame bound itself. A base layer can be scheduled so late that - // "newest settled base layer" has already raced ahead of it; its enhancement layer is - // then correctly dropped as stale, and no further base-layer arrival happens within - // this batch to age it out before the `DispatchGroup` above finishes. It is still - // queued, not lost: a few harmless, never-pairing nudges (fresh buffers, PTS values - // outside this test's range) advance `blArrivalSeq` past it. - for i in 0..<(DualLayerVideoDecoder.maxLayerSkewFrames + 1) { - guard let nudge = Self.baseLayer() else { break } - nudgeIDs.insert(ObjectIdentifier(nudge)) - decoder.handleBaseLayerFrame(baseBuffer: nudge, pts: Self.pts(Int64(900 + i)), hdr10Data: nil) - } - try await waitFor { - deliveries.snapshot.filter { sentIDs.contains(ObjectIdentifier($0.buffer)) }.count >= frames - } - } - // `allSatisfy`, not a `filter`: an enhancement-layer buffer (or - // anything else) delivered on its own is what this catches; sent - // base-layer frames and the nudges above are the only buffers ever - // allowed out. - let allowed = sentIDs.union(nudgeIDs) - #expect(deliveries.snapshot.allSatisfy { allowed.contains(ObjectIdentifier($0.buffer)) }, - "only sent base-layer frames or the nudges that age out stranded ones may ever be delivered") - let got = deliveries.snapshot.filter { sentIDs.contains(ObjectIdentifier($0.buffer)) } - #expect(got.count == frames) - #expect(Set(got.map { ObjectIdentifier($0.buffer) }).count == frames) - - // Teardown under load: arrivals racing close() are either delivered - // before it or dropped after it; nothing lands past the marker. - let teardown = DispatchGroup() - for i in frames..<(frames + 8) { - teardown.enter() - DispatchQueue.global().async { - if let b = Self.baseLayer(), let e = Self.enhancementLayer() { - decoder.handleBaseLayerFrame(baseBuffer: b, pts: Self.pts(Int64(i)), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: e, pts: Self.pts(Int64(i))) + + var snapshot: [String] { lock.lock(); defer { lock.unlock() }; return violations } + } + + @Test("flushing from another thread in a tight loop, while frames keep arriving, never delivers a frame paired before flush k started after flush k returns (I3)") + func flushFromAnotherThreadInATightLoopKeepsInvariantI3() async throws { + // An elapsed-time bound and the epoch bump cannot detect an I3 + // violation, and neither can `isStrictlyIncreasing` alone: a pre-flush + // frame delivered late still carries a lower PTS than every post-flush + // frame the ever-increasing producer goes on to emit. + // + // So this test uses a per-flush detector: two lock-protected counters, "flushes started" and + // "flushes returned". Each frame is tagged, the instant its pairing + // call returns, with the "started" count at that moment — how many + // flushes had already been kicked off by the time this frame took + // its ticket. Each delivery reads the "returned" count at the moment + // `onFrame` fires — how many flushes have already fully completed by + // the time this frame reached the host. The rule: a frame whose + // pairing call returned before flush k started (tag < k) must never + // reach `onFrame` after flush k has returned (returnedAtDelivery >= + // k). Collapsing "there exists such a k" to a single per-frame + // comparison is valid because the tightest possible k for any + // violation is the number of flushes already returned at delivery + // time itself: if `tag < returnedAtDelivery`, then k = + // returnedAtDelivery satisfies `tag < k` and `k <= returnedAtDelivery` + // simultaneously, which is exactly the violation shape. + let counters = FlushCounters() + let tracker = ViolationTracker() + + let deliveries = Deliveries() + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) + decoder.onFrame = { buffer, pts, hdr10 in + let returnedAtDelivery = counters.returnedCount + tracker.checkDelivery(ptsValue: pts.value, returnedAtDelivery: returnedAtDelivery) + deliveries.record(buffer, pts, hdr10) + } + decoder.primeForTesting(enhancementLayer: false) + defer { decoder.close() } + + let stop = BoolFlag() + + let producing = BlockingWork.detached { () -> Void in + var i: Int64 = 0 + while !stop.isSet { + if let bl = Self.baseLayer() { + let pts = Self.pts(i) + tracker.registerInFlight(ptsValue: pts.value) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: pts, hdr10Data: nil) + // The real tag is taken AFTER the handler returns, matching the detector's rule. + let tag = counters.startedCount + tracker.recordStartedTag(tag, forPTSValue: pts.value) + } + i += 1 } - teardown.leave() } - } - teardown.enter() - DispatchQueue.global().async { - decoder.flush() - decoder.close() - teardown.leave() - } - await withCheckedContinuation { cont in teardown.notify(queue: .global()) { cont.resume() } } - deliveries.setMarker() - if let b = Self.baseLayer(), let e = Self.enhancementLayer() { - decoder.handleBaseLayerFrame(baseBuffer: b, pts: Self.pts(10_000), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: e, pts: Self.pts(10_000)) - } - decoder.quiesceForTesting() - #expect(deliveries.snapshot.allSatisfy { !$0.afterMarker }) - } - // MARK: - Delivery queue - - /// Fake composer whose every composition fails (completes with nil), as a GPU error does. - private final class FailingFakeComposer: FelComposing, @unchecked Sendable { - func acquireSlot() {} - func releaseSlot() {} - func flush() {} - func composeAsync( - baseBuffer: CVPixelBuffer, - enhancementBuffer: CVPixelBuffer, - params: DoviMetalParams, - frameIndex: UInt32, - completion: @escaping @Sendable (CVPixelBuffer?) -> Void - ) { - completion(nil) - } - func composeAsyncWithAcquiredSlot( - baseBuffer: CVPixelBuffer, - enhancementBuffer: CVPixelBuffer, - params: DoviMetalParams, - frameIndex: UInt32, - completion: @escaping @Sendable (CVPixelBuffer?) -> Void - ) { - completion(nil) - } - } - - /// Fake composer whose Nth call (1-indexed) never calls its completion at - /// all — the one failure mode a ticket cannot self-report, unlike a - /// bypass or a "compose failed" fallback. Every other call completes - /// synchronously with the base layer, like `ImmediateFakeComposer`. - private final class DroppingFakeComposer: FelComposing, @unchecked Sendable { - private let dropOnCall: Int - private let lock = NSLock() - private var calls = 0 - init(dropOnCall: Int) { self.dropOnCall = dropOnCall } - func acquireSlot() {} - func releaseSlot() {} - func flush() {} - func composeAsync( - baseBuffer: CVPixelBuffer, - enhancementBuffer: CVPixelBuffer, - params: DoviMetalParams, - frameIndex: UInt32, - completion: @escaping @Sendable (CVPixelBuffer?) -> Void - ) { - composeAsyncWithAcquiredSlot(baseBuffer: baseBuffer, enhancementBuffer: enhancementBuffer, params: params, frameIndex: frameIndex, completion: completion) - } - func composeAsyncWithAcquiredSlot( - baseBuffer: CVPixelBuffer, - enhancementBuffer: CVPixelBuffer, - params: DoviMetalParams, - frameIndex: UInt32, - completion: @escaping @Sendable (CVPixelBuffer?) -> Void - ) { - lock.lock(); calls += 1; let n = calls; lock.unlock() - if n == dropOnCall { - // Simulates a completion that never fires: no call to - // `completion`, so the ticket this composition took is never - // fulfilled at all. - return + // Flush back-to-back, on another thread, for as long as the producer + // above keeps feeding frames concurrently. Each call must still + // return promptly (proving no deadlock under contention) and bump the + // feed epoch (proving it actually ran, not a silent no-op). + for _ in 0..<200 { + counters.beginFlush() + let epochBefore = decoder.feedEpoch + // A deadlock under producer load shows as this loop never finishing, which the suite's + // time limit reports; a wall-clock bound per flush would only add scheduler noise. + decoder.flush() + counters.endFlush() + #expect(decoder.feedEpoch == epochBefore + 1) } - completion(baseBuffer) - } - } - @Test("a composition whose completion never fires costs exactly one frame, not the session") - func lostCompletionCostsExactlyOneFrame() async throws { - let threshold = DoviFelComposer.maxFramesInFlight + DualLayerVideoDecoder.maxLayerSkewFrames + 2 - let composer = DroppingFakeComposer(dropOnCall: 1) - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: composer) - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - defer { decoder.close() } - - // Every frame pairs immediately (EL arrives first, so `handleBaseLayerFrame` - // composes on the spot) so ticket `i` and loop index `i` coincide. The - // first composition's ticket (0) is the one `DroppingFakeComposer` drops; - // enough frames follow to push it past `orderedDelivery`'s stall - // threshold (`maxFramesInFlight + K + 2`). - let frames = threshold + 6 - for i in 0.. UnsafeMutablePointer { - let slice = hevcNAL(type: 1, payload: [0x11, 0x22, 0x33, 0x44]) - return try #require(makeAvccPacket([hevcVPS, hevcSPS, hevcPPS, slice], pts: pts)) - } + // Simulate a FEL packet: EL packet would be freed, not fed to any decoder + let bl = try #require(Self.baseLayer()) + let el = try #require(Self.enhancementLayer()) + decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - #if DEBUG - @Test("a post-seek packet fed with the new epoch between the dual epoch bump and the sub-decoder flush reaches VideoToolbox; a pre-seek one does not") - func postSeekPacketInTheFlushWindowIsNotDropped() async throws { - // The host feeds from the demux thread while a seek's flush() runs. It reads the new epoch the - // moment flush() publishes it. The sub-decoders used to move only in their own flush(), later, - // so a packet fed in this window passed the dual gate and was refused by a decoder still on - // the old epoch: the post-seek IRAP never reached VideoToolbox. - let primary = HardwareVideoDecoder() - let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer(), primaryDecoder: primary, enhancementDecoder: nil) - decoder.onFrame = { _, _, _ in } - defer { decoder.flushWillTakeLifecycleLockHookForTesting = nil; decoder.flushRaceHookForTesting = nil; decoder.close() } - let submitted = LockedCounter() - primary.onSubmittedToVideoToolboxForTesting = { submitted.increment() } - - // The first packet opens the primary decoder from its in-band parameter sets. - let pre = try Self.inBandPacket(pts: 0) - defer { var p: UnsafeMutablePointer? = pre; trackedPacketFree(&p) } - decoder.decode(packet: pre, epoch: decoder.feedEpoch) - if decoder.fatalDecodeError != nil { try Test.cancel("hardware HEVC session unavailable: \(decoder.fatalDecodeError!)") } - #expect(submitted.value == 1) - let oldEpoch = decoder.feedEpoch - - let post = try Self.inBandPacket(pts: 90000) - defer { var p: UnsafeMutablePointer? = post; trackedPacketFree(&p) } - let submittedInWindow = LockedCounter() - decoder.flushRaceHookForTesting = { - // flush() has published the new epoch and not yet touched the sub-decoders. - decoder.decode(packet: post, epoch: decoder.feedEpoch) - submittedInWindow.set(submitted.value) - } - decoder.flush() - #expect(decoder.feedEpoch == oldEpoch + 1) - #expect(primary.feedEpoch == oldEpoch + 1, "the sub-decoder's own flush must not move the epoch a second time") - #expect(submittedInWindow.value == 2, "the post-seek packet fed inside the window never reached VideoToolbox") - - // DEC-4: a packet decided under the old epoch must not enter VideoToolbox after the flush. - decoder.decode(packet: pre, epoch: oldEpoch) - #expect(submitted.value == 2) - // And the epoch the host would read next is accepted. - decoder.decode(packet: post, epoch: decoder.feedEpoch) - #expect(submitted.value == 3) - } - #endif - - /// Fake whose in-band open always fails. - private final class FailingOpenFakeDecoder: SingleLayerVideoDecoder, @unchecked Sendable { - private let lock = NSLock() - private var _opens = 0, _decodes = 0 - var openCount: Int { lock.lock(); defer { lock.unlock() }; return _opens } - var decodeCount: Int { lock.lock(); defer { lock.unlock() }; return _decodes } - struct OpenFailed: Error {} - var onFrame: DecodedFrameHandler? - var onFirstHDR10PlusDetected: (@Sendable () -> Void)? - var onA53Captions: (@Sendable ([CCDataParser.CCTriplet], Double) -> Void)? - var onDecodedFormat: (@Sendable (DecodedVideoFormat) -> Void)? - var skipUntilPTS: CMTime? - var feedEpoch: UInt64 = 0 - func open(stream: UnsafeMutablePointer, onFrame: @escaping DecodedFrameHandler) throws {} - func open(parameterSets: [[UInt8]], timeBase: AVRational, onFrame: @escaping DecodedFrameHandler) throws { - lock.lock(); _opens += 1; lock.unlock() - throw OpenFailed() - } - func decode(packet: UnsafeMutablePointer, epoch: UInt64?) { lock.lock(); _decodes += 1; lock.unlock() } - func flush() {} - func close() {} - } + // EL arrives first - should be queued but never paired because no EL decoder exists + decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) + // BL arrives - should bypass and deliver immediately (no composition possible) + decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) - @Test("an in-band primary open that fails is reported once and not retried on every later packet") - func failedInBandOpenFailsOnce() throws { - let primary = FailingOpenFakeDecoder() - let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer(), primaryDecoder: primary, enhancementDecoder: nil) - defer { decoder.close() } - let reported = LockedCounter() - decoder.onFatalDecodeError = { _ in reported.increment() } - let lines = LockedCounter() - let previous = EngineLog.handler - EngineLog.handler = { line in if line.contains("failed to open the primary decoder") { lines.increment() } } - defer { EngineLog.handler = previous } - - for i in 0..<5 { - let pkt = try Self.inBandPacket(pts: Int64(i) * 3000) - decoder.decode(packet: pkt) - var p: UnsafeMutablePointer? = pkt - trackedPacketFree(&p) - } - #expect(primary.openCount == 1, "the open was retried on a later packet") - #expect(reported.value == 1) - #expect(lines.value == 1) - #expect(decoder.fatalDecodeError is FailingOpenFakeDecoder.OpenFailed) - #expect(primary.decodeCount == 0) - } + try await waitFor { deliveries.count == 1 } + let got = deliveries.snapshot.first + #expect(got != nil, "base layer must be delivered") + #expect(got?.buffer === bl, "delivered buffer must be the base layer (bypass)") + #expect(CMTimeCompare(got?.pts ?? .invalid, Self.pts(1)) == 0) - @Test("an enhancement decoder that fails to open is logged once and not retried on every later packet") - func failedEnhancementOpenFailsOnce() throws { - let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer(), primaryDecoder: FailingOpenFakeDecoder(), enhancementDecoder: nil) - let enhancement = FailingOpenFakeDecoder() - let built = LockedCounter() - decoder.makeEnhancementDecoder = { built.increment(); return enhancement } - defer { decoder.close() } - let lines = LockedCounter() - let previous = EngineLog.handler - EngineLog.handler = { line in if line.contains("failed to initialize EL decoder") { lines.increment() } } - defer { EngineLog.handler = previous } - - for i in 0..<5 { - let pkt = try Self.inBandPacket(pts: Int64(i) * 3000) - decoder.ensureEnhancementDecoder(from: pkt) - var p: UnsafeMutablePointer? = pkt - trackedPacketFree(&p) - } - #expect(enhancement.openCount == 1, "the open was retried on a later packet") - #expect(built.value == 1, "a new session was built for a later packet") - #expect(lines.value == 1) - #expect(decoder.enhancementDecoderOpenError is FailingOpenFakeDecoder.OpenFailed) - #expect(decoder.fatalDecodeError == nil, "a missing enhancement layer leaves the base layer to show") - // close() starts the next session clean. - decoder.close() - #expect(decoder.enhancementDecoderOpenError == nil) - } + // Primary decoder never received the EL packet (it doesn't exist), but BL was fed + // Note: primary.decodeCount would be 0 because we bypassed decode(packet:) and called handlers directly + // The key assertion is that no enhancement decoder was created/engaged. + } - @Test("the alignment's restamp bound is what the pairing skew bound can absorb") - func alignmentBoundTracksSkewBound() { - #expect(DualLayerVideoDecoder.makeAlignment().maxBackwardRestamp == DualLayerVideoDecoder.maxLayerSkewFrames - 1) - } + // MARK: - HardwareVideoDecoder.CustomBlockSource Lifecycle - @Test("a crafted raw timestamp that overflows the time-base product yields no key instead of trapping") - func canonicalKeyIsOverflowSafe() { - let tb = AVRational(num: 1, den: 90000) - #expect(DualLayerVideoDecoder.canonicalKey(rawPTS: 90000, timeBase: tb) == 90000) - #expect(DualLayerVideoDecoder.canonicalKey(rawPTS: Int64.min, timeBase: tb) == Int64.min) - let wide = AVRational(num: 1_000_000, den: 1) - #expect(DualLayerVideoDecoder.canonicalKey(rawPTS: Int64.max, timeBase: wide) == Int64.min) - #expect(DualLayerVideoDecoder.canonicalKey(rawPTS: Int64.min + 1, timeBase: wide) == Int64.min) - // Large but representable products saturate in CoreMedia rather than trapping. - _ = DualLayerVideoDecoder.canonicalKey(rawPTS: Int64.max / 2, timeBase: AVRational(num: 2, den: 1)) - } + @Test("CustomBlockSource holds av_buffer_ref and unrefs idempotently") + func customBlockSourceLifecycle() throws { + guard let buf = av_buffer_alloc(256) else { + Issue.record("Failed to allocate AVBuffer") + return + } + var bufRef: UnsafeMutablePointer? = buf + defer { + if bufRef != nil { + av_buffer_unref(&bufRef) + } + } - @Test("a slow onFrame does not block pairing or flush() behind the decoder lock", - .timeLimit(.minutes(5))) - func slowOnFrameDoesNotBlockDecoderOperations() async throws { - // The old `deliver()` took the decoder's lock and called `emit` (hence - // `onFrame`) from inside it, so a slow host callback would have stalled - // every later frame handler and `flush()` behind the same lock. With - // `OrderedDelivery` owning `emit` on its own queue, pairing a new frame - // never waits on `onFrame` at all — but `flush()` is a different claim: - // its `orderedDelivery.barrier()` call legitimately waits for whatever - // `emit` is currently in the middle of before draining can proceed. The - // bound here is "as long as `onFrame` itself takes", not "instantly" - // and not "forever". - // `firstCallStarted` is polled rather than waited on with a semaphore: - // `DispatchSemaphore.wait` is unavailable from this `async` test body - // (it would block the cooperative thread pool), but it is exactly - // right *inside* the plain, non-async `onFrame` closure below, which - // runs on `OrderedDelivery`'s own queue and is what this test needs - // to hold still to prove pairing is not waiting on it. - let firstCallStarted = BoolFlag() - let releaseFirstCall = DispatchSemaphore(value: 0) - let callCount = LockedCounter() - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) - decoder.onFrame = { buffer, pts, hdr10 in - let isFirst = callCount.incrementAndCheckFirst() - if isFirst { - firstCallStarted.set() - releaseFirstCall.wait() - } - deliveries.record(buffer, pts, hdr10) - } - decoder.primeForTesting(enhancementLayer: false) - defer { - releaseFirstCall.signal() // in case an assertion below fails first - decoder.close() - } + #expect(av_buffer_get_ref_count(buf) == 1) - let bl0 = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: bl0, pts: Self.pts(0), hdr10Data: nil) - try await waitFor { firstCallStarted.isSet } - #expect(firstCallStarted.isSet, "onFrame for frame 0 never started") - - // While that first `onFrame` call is still parked, pairing a new frame - // must not wait on it at all: `handleBaseLayerFrame` only ever - // `fulfil`s a ticket onto `OrderedDelivery`'s queue (`queue.async`, - // non-blocking) and never calls into `emit`. The call returning at all, - // with the release semaphore still unsignalled, is the proof: if it - // waited on the parked `onFrame` it would never return and the test's - // time limit would report it. No wall-clock threshold is involved. - let bl1 = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: bl1, pts: Self.pts(1), hdr10Data: nil) - - // `flush()` legitimately waits for the in-flight `emit` (its `barrier()` - // waits on the queue `emit` runs on), so it runs on a thread of its own - // and the release is signalled from here. It must then finish; if the - // decoder's lock were held across the wait it would hang, and the time - // limit would say so. - let flushDone = BoolFlag() - Thread.detachNewThread { - decoder.flush() - flushDone.set() - } - releaseFirstCall.signal() - try await waitFor { flushDone.isSet } - #expect(flushDone.isSet, "flush() never returned after the slow onFrame call was released") + do { + let source = try #require(HardwareVideoDecoder.CustomBlockSource(buf: buf)) + #expect(av_buffer_get_ref_count(buf) == 2) + #expect(source.dataPtr == UnsafeMutableRawPointer(buf.pointee.data!)) - decoder.quiesceForTesting() - } + // Test idempotent releaseBuffer + source.releaseBuffer() + #expect(av_buffer_get_ref_count(buf) == 1) + #expect(source.avBuf == nil) - /// Thread-safe increment that also reports whether this call was the - /// first one, without a data race between the check and the increment. - private final class LockedCounter: @unchecked Sendable { - private let lock = NSLock() - private var count = 0 - func incrementAndCheckFirst() -> Bool { - lock.lock(); defer { lock.unlock() } - count += 1 - return count == 1 - } - func increment() { lock.lock(); count += 1; lock.unlock() } - func set(_ v: Int) { lock.lock(); count = v; lock.unlock() } - var value: Int { lock.lock(); defer { lock.unlock() }; return count } - } + // Second releaseBuffer call is a no-op (idempotent, no double-unref) + source.releaseBuffer() + #expect(av_buffer_get_ref_count(buf) == 1) + } + // After deinit of source, refcount stays 1 (no double-unref) + #expect(av_buffer_get_ref_count(buf) == 1) + } - /// A boolean settable from one thread and polled from another, with no - /// torn reads. Used both as a "stop requested" flag and, in - /// `slowOnFrameDoesNotBlockDecoderOperations`, as a "started" flag — the - /// name is generic on purpose. - private final class BoolFlag: @unchecked Sendable { - private let lock = NSLock() - private var value = false - func set() { lock.lock(); value = true; lock.unlock() } - var isSet: Bool { lock.lock(); defer { lock.unlock() }; return value } - } + @Test("CustomBlockSource addresses the packet payload, not buf->data, when they differ (Matroska packets sit at +4)") + func customBlockSourceHonoursPacketDataOffset() throws { + let buf = try #require(av_buffer_alloc(256)) + var bufRef: UnsafeMutablePointer? = buf + defer { av_buffer_unref(&bufRef) } - /// Lock-protected "flushes started"/"flushes returned" counters for the - /// per-flush I3 detector in `flushFromAnotherThreadInATightLoopKeepsInvariantI3`. - private final class FlushCounters: @unchecked Sendable { - private let lock = NSLock() - private var started = 0 - private var returned = 0 - /// Call immediately before `decoder.flush()`. Returns the new count. - @discardableResult - func beginFlush() -> Int { lock.lock(); started += 1; defer { lock.unlock() }; return started } - /// Call immediately after `decoder.flush()` returns. - func endFlush() { lock.lock(); returned += 1; lock.unlock() } - var startedCount: Int { lock.lock(); defer { lock.unlock() }; return started } - var returnedCount: Int { lock.lock(); defer { lock.unlock() }; return returned } - } + let payload = try #require(buf.pointee.data).advanced(by: 4) + let source = try #require(HardwareVideoDecoder.CustomBlockSource(buf: buf, data: payload)) + #expect(source.dataPtr == UnsafeMutableRawPointer(payload)) + #expect(source.dataPtr != UnsafeMutableRawPointer(buf.pointee.data!)) + source.releaseBuffer() + } - /// Records each frame's "started" tag (keyed by its unique PTS value) and - /// any I3 violation the detector in - /// `flushFromAnotherThreadInATightLoopKeepsInvariantI3` finds — a class, - /// like `Deliveries`/`FlushCounters` above, so the producer task and the - /// `onFrame` closure can share it as a `let` capture rather than a `var` - /// one, which Swift 6 strict concurrency refuses to let a closure and a - /// detached task mutate directly. - private final class ViolationTracker: @unchecked Sendable { - private let lock = NSLock() - private var startedTagByPTS: [Int64: Int] = [:] - private var violations: [String] = [] - - /// Marks a frame as being inside its pairing call. Delivery is asynchronous and can run before - /// the call returns, so the frame has to be known to the detector before the call starts; the - /// real tag replaces this once the call returns. - func registerInFlight(ptsValue: Int64) { - lock.lock(); startedTagByPTS[ptsValue] = Self.inFlight; lock.unlock() - } - - func recordStartedTag(_ tag: Int, forPTSValue ptsValue: Int64) { - lock.lock(); startedTagByPTS[ptsValue] = tag; lock.unlock() - } - - private static let inFlight = Int.max - - /// Checks one delivery against the rule and records a violation if - /// found. Call from `onFrame`, passing the "returned" count read at - /// that moment. - func checkDelivery(ptsValue: Int64, returnedAtDelivery: Int) { - lock.lock() - defer { lock.unlock() } - guard let startedTag = startedTagByPTS[ptsValue] else { - violations.append("pts=\(ptsValue) delivered with no tag registered") + @Test("CMBlockBuffer with CustomBlockSource holds av_buffer_ref and unrefs on deallocation") + func cmBlockBufferWithCustomBlockSourceLifecycle() throws { + guard let buf = av_buffer_alloc(512) else { + Issue.record("Failed to allocate AVBuffer") return } - // Still inside its pairing call: it cannot have been paired before a flush that has since returned. - guard startedTag != Self.inFlight, startedTag < returnedAtDelivery else { return } - violations.append("pts=\(ptsValue) startedTag=\(startedTag) returnedAtDelivery=\(returnedAtDelivery)") - } + var bufRef: UnsafeMutablePointer? = buf + defer { + if bufRef != nil { + av_buffer_unref(&bufRef) + } + } - var snapshot: [String] { lock.lock(); defer { lock.unlock() }; return violations } - } + #expect(av_buffer_get_ref_count(buf) == 1) - @Test("flushing from another thread in a tight loop, while frames keep arriving, never delivers a frame paired before flush k started after flush k returns (I3)") - func flushFromAnotherThreadInATightLoopKeepsInvariantI3() async throws { - // An elapsed-time bound and the epoch bump cannot detect an I3 - // violation, and neither can `isStrictlyIncreasing` alone: a pre-flush - // frame delivered late still carries a lower PTS than every post-flush - // frame the ever-increasing producer goes on to emit. - // - // So this test uses a per-flush detector: two lock-protected counters, "flushes started" and - // "flushes returned". Each frame is tagged, the instant its pairing - // call returns, with the "started" count at that moment — how many - // flushes had already been kicked off by the time this frame took - // its ticket. Each delivery reads the "returned" count at the moment - // `onFrame` fires — how many flushes have already fully completed by - // the time this frame reached the host. The rule: a frame whose - // pairing call returned before flush k started (tag < k) must never - // reach `onFrame` after flush k has returned (returnedAtDelivery >= - // k). Collapsing "there exists such a k" to a single per-frame - // comparison is valid because the tightest possible k for any - // violation is the number of flushes already returned at delivery - // time itself: if `tag < returnedAtDelivery`, then k = - // returnedAtDelivery satisfies `tag < k` and `k <= returnedAtDelivery` - // simultaneously, which is exactly the violation shape. - let counters = FlushCounters() - let tracker = ViolationTracker() - - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) - decoder.onFrame = { buffer, pts, hdr10 in - let returnedAtDelivery = counters.returnedCount - tracker.checkDelivery(ptsValue: pts.value, returnedAtDelivery: returnedAtDelivery) - deliveries.record(buffer, pts, hdr10) - } - decoder.primeForTesting(enhancementLayer: false) - defer { decoder.close() } - - let stop = BoolFlag() - - let producing = BlockingWork.detached { () -> Void in - var i: Int64 = 0 - while !stop.isSet { - if let bl = Self.baseLayer() { - let pts = Self.pts(i) - tracker.registerInFlight(ptsValue: pts.value) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: pts, hdr10Data: nil) - // The real tag is taken AFTER the handler returns, matching the detector's rule. - let tag = counters.startedCount - tracker.recordStartedTag(tag, forPTSValue: pts.value) - } - i += 1 + do { + let size = 512 + let blockSource = try #require(HardwareVideoDecoder.CustomBlockSource(buf: buf)) + let (blockBuffer, status) = HardwareVideoDecoder.makeBlockBuffer(wrapping: blockSource, size: size) + #expect(status == kCMBlockBufferNoErr) + #expect(blockBuffer != nil) + // Block buffer holds av_buffer_ref + #expect(av_buffer_get_ref_count(buf) == 2) + + // Verify memory block pointer is accessible and matches buf data + var lengthAtOffset = 0 + var totalLength = 0 + var dataPointer: UnsafeMutablePointer? + let accessStatus = CMBlockBufferGetDataPointer( + blockBuffer!, + atOffset: 0, + lengthAtOffsetOut: &lengthAtOffset, + totalLengthOut: &totalLength, + dataPointerOut: &dataPointer + ) + #expect(accessStatus == kCMBlockBufferNoErr) + #expect(totalLength == size) + #expect(dataPointer == UnsafeMutablePointer(OpaquePointer(buf.pointee.data))) } - } - // Flush back-to-back, on another thread, for as long as the producer - // above keeps feeding frames concurrently. Each call must still - // return promptly (proving no deadlock under contention) and bump the - // feed epoch (proving it actually ran, not a silent no-op). - for _ in 0..<200 { - counters.beginFlush() - let epochBefore = decoder.feedEpoch - // A deadlock under producer load shows as this loop never finishing, which the suite's - // time limit reports; a wall-clock bound per flush would only add scheduler noise. - decoder.flush() - counters.endFlush() - #expect(decoder.feedEpoch == epochBefore + 1) + // CMBlockBuffer went out of scope: FreeBlock ran takeRetainedValue and unref'd avBuf + #expect(av_buffer_get_ref_count(buf) == 1) } - stop.set() - await producing.value - decoder.quiesceForTesting() + @Test("CustomBlockSource cleanup on CMBlockBuffer creation failure balances retain") + func customBlockSourceCleanupOnCreationFailure() throws { + guard let buf = av_buffer_alloc(256) else { + Issue.record("Failed to allocate AVBuffer") + return + } + var bufRef: UnsafeMutablePointer? = buf + defer { + if bufRef != nil { + av_buffer_unref(&bufRef) + } + } - let finalViolations = tracker.snapshot - #expect(finalViolations.isEmpty, "I3 violated: \(finalViolations.prefix(5))") + #expect(av_buffer_get_ref_count(buf) == 1) - // Order is still a real property worth keeping, just not the one - // that actually proves I3 (see the comment above): any I3 violation - // this detector doesn't already flag would have to slip a stale - // frame in without disturbing PTS order at all, which the pairing - // ticket order already makes exceedingly unlikely — but it costs - // nothing extra to keep checking. - #expect(Self.isStrictlyIncreasing(deliveries.snapshot)) - } + let blockSource = try #require(HardwareVideoDecoder.CustomBlockSource(buf: buf)) + // A payload longer than the block is refused by CoreMedia, which is the failure the decoder's + // own wrapper has to clean up after. + let (blockBuffer, status) = HardwareVideoDecoder.makeBlockBuffer(wrapping: blockSource, size: 256, dataLength: 512) + #expect(status != kCMBlockBufferNoErr) + #expect(blockBuffer == nil) - // MARK: - Known defects - - @Test("a pruned base-layer frame cannot be delivered after a newer composed frame", .enabled(if: Self.metalAvailable, "needs a Metal device")) - func prunedFrameDoesNotOvertakeNewerFrames() async throws { - let deliveries = Deliveries() - let decoder = Self.decoder(recordingInto: deliveries, enhancementLayer: true) - defer { decoder.close() } - - // Frame 0's enhancement layer is lost, so it waits in the base-layer queue. - let orphan = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: orphan, pts: Self.pts(0), hdr10Data: nil) - // Frame 1 pairs immediately and is composed. - let bl1 = try #require(Self.baseLayer()) - let el1 = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - decoder.handleEnhancementLayerFrame(elBuffer: el1, pts: Self.pts(1)) - decoder.handleBaseLayerFrame(baseBuffer: bl1, pts: Self.pts(1), hdr10Data: nil) - // More orphans until frame 0 is pushed out of the queue. - var next: Int64 = 2 - while !deliveries.snapshot.contains(where: { $0.buffer === orphan }) && next < 60 { - let bl = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(next), hdr10Data: nil) - next += 1 + // Retain count of AVBuffer drops back to 1 and blockSource is deallocated without leaking + #expect(av_buffer_get_ref_count(buf) == 1) } - try await waitFor { deliveries.snapshot.contains { $0.buffer !== orphan && CMTimeCompare($0.pts, Self.pts(1)) == 0 } } - let got = deliveries.snapshot - #expect(Self.isStrictlyIncreasing(got)) - } + // MARK: - HardwareVideoDecoder.decode with CustomBlockSource vs Fallback Copy - @Test("a lost enhancement layer delays its base layer by at most K frames, in order") - func lostEnhancementLayerDelaysBaseLayerByAtMostKFrames() async throws { - // Synchronous composer, so delivery is synchronous and the bound is exact: - // frame 0 waits while newer pairs settle, then goes out under its own - // (oldest) ticket once K newer base-layer frames have arrived. - let k = DualLayerVideoDecoder.maxLayerSkewFrames - let deliveries = Deliveries() - let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - decoder.primeForTesting(enhancementLayer: true) - defer { decoder.close() } - - // Not `deliveries.count == 0`: delivery is always one more - // `queue.async` hop away, so it would read 0 whether or not frame 0 - // was still held back. `blQueueCount` is synchronous and - // deterministic: `collectDueBypasses` runs entirely under `lock` - // inside `handleBaseLayerFrame`, so by the time that call returns, - // frame 0 is provably still queued (not yet bypassed) if and only - // if fewer than K newer base-layer frames have arrived. - let orphan = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: orphan, pts: Self.pts(0), hdr10Data: nil) - #expect(decoder.stateSnapshotForTesting.blQueueCount == 1, "a base-layer frame with a pending enhancement layer must wait for it") - - for i in 1..? = buf + defer { + if bufRef != nil { + av_buffer_unref(&bufRef) + } + } - // MARK: - Composer unavailable - - @Test("with no composer, EL decoder is never engaged and base layer is delivered") - func noComposerDisablesEnhancementLayerAndDeliversBaseLayer() async throws { - // Inject nil composer via internal initializer (bypasses open() which would throw). - let primary = CountingFakeDecoder() - let decoder = DualLayerVideoDecoder( - composer: nil, - primaryDecoder: primary, - enhancementDecoder: nil - ) - let deliveries = Deliveries() - decoder.onFrame = { buffer, pts, hdr10 in deliveries.record(buffer, pts, hdr10) } - // Prime as FEL stream (what classifyPacketResultForTesting would set after first RPU) - decoder.primeStreamForTesting(.fel) - defer { decoder.close() } - - // Even though stream is classified FEL, no composer → no EL engagement - #expect(decoder.engagesEnhancementLayerForTesting() == false, "no composer must disable EL engagement") - #expect(decoder.stateSnapshotForTesting.enhancementEngaged == false) - - // Simulate a FEL packet: EL packet would be freed, not fed to any decoder - let bl = try #require(Self.baseLayer()) - let el = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - - // EL arrives first - should be queued but never paired because no EL decoder exists - decoder.handleEnhancementLayerFrame(elBuffer: el, pts: Self.pts(1)) - // BL arrives - should bypass and deliver immediately (no composition possible) - decoder.handleBaseLayerFrame(baseBuffer: bl, pts: Self.pts(1), hdr10Data: nil) - - try await waitFor { deliveries.count == 1 } - let got = deliveries.snapshot.first - #expect(got != nil, "base layer must be delivered") - #expect(got?.buffer === bl, "delivered buffer must be the base layer (bypass)") - #expect(CMTimeCompare(got?.pts ?? .invalid, Self.pts(1)) == 0) - - // Primary decoder never received the EL packet (it doesn't exist), but BL was fed - // Note: primary.decodeCount would be 0 because we bypassed decode(packet:) and called handlers directly - // The key assertion is that no enhancement decoder was created/engaged. - } + #expect(av_buffer_get_ref_count(buf) == 1) - // MARK: - HardwareVideoDecoder.CustomBlockSource Lifecycle + // Valid buffer succeeds + let validSource = HardwareVideoDecoder.CustomBlockSource(buf: buf) + #expect(validSource != nil) + validSource?.releaseBuffer() + #expect(av_buffer_get_ref_count(buf) == 1) - @Test("CustomBlockSource holds av_buffer_ref and unrefs idempotently") - func customBlockSourceLifecycle() throws { - guard let buf = av_buffer_alloc(256) else { - Issue.record("Failed to allocate AVBuffer") - return - } - var bufRef: UnsafeMutablePointer? = buf - defer { - if bufRef != nil { - av_buffer_unref(&bufRef) + // Buffer with nil data returns nil without leaking av_buffer_ref + let originalData = buf.pointee.data + buf.pointee.data = nil + let nilSource = HardwareVideoDecoder.CustomBlockSource(buf: buf) + #expect(nilSource == nil) + #expect(av_buffer_get_ref_count(buf) == 1) + buf.pointee.data = originalData + } + + @Test("HardwareVideoDecoder.decode with useCustomBlockSource holds av_buffer_ref and decrements on release") + func hardwareVideoDecoderHoldsAndDecrementsBufferRef() throws { + let decoder = HardwareVideoDecoder() + do { + try decoder.open( + parameterSets: [Self.hevcVPS, Self.hevcSPS, Self.hevcPPS], + timeBase: AVRational(num: 1, den: 90000), + onFrame: { _, _, _ in } + ) + } catch { + // Hardware HEVC decompression session might not be supported in some environments: + // reported as skipped, not passed. + try Test.cancel("hardware HEVC session creation failed: \(error)") } - } + defer { decoder.close() } - #expect(av_buffer_get_ref_count(buf) == 1) + decoder.useCustomBlockSource = true - do { - let source = try #require(HardwareVideoDecoder.CustomBlockSource(buf: buf)) - #expect(av_buffer_get_ref_count(buf) == 2) - #expect(source.dataPtr == UnsafeMutableRawPointer(buf.pointee.data!)) + guard let avBuf = av_buffer_alloc(64) else { + Issue.record("Failed to allocate AVBuffer") + return + } + var avBufRef: UnsafeMutablePointer? = avBuf + defer { + if avBufRef != nil { + av_buffer_unref(&avBufRef) + } + } - // Test idempotent releaseBuffer - source.releaseBuffer() - #expect(av_buffer_get_ref_count(buf) == 1) - #expect(source.avBuf == nil) + // Initialize with a 4-byte length-prefixed NAL (SEI prefix) + let dataPtr = avBuf.pointee.data! + dataPtr[0] = 0; dataPtr[1] = 0; dataPtr[2] = 0; dataPtr[3] = 60 + dataPtr[4] = 0x4E; dataPtr[5] = 0x01 + for i in 6..<64 { dataPtr[i] = 0x80 } - // Second releaseBuffer call is a no-op (idempotent, no double-unref) - source.releaseBuffer() - #expect(av_buffer_get_ref_count(buf) == 1) - } - // After deinit of source, refcount stays 1 (no double-unref) - #expect(av_buffer_get_ref_count(buf) == 1) - } + #expect(av_buffer_get_ref_count(avBuf) == 1) - @Test("CustomBlockSource addresses the packet payload, not buf->data, when they differ (Matroska packets sit at +4)") - func customBlockSourceHonoursPacketDataOffset() throws { - let buf = try #require(av_buffer_alloc(256)) - var bufRef: UnsafeMutablePointer? = buf - defer { av_buffer_unref(&bufRef) } - - let payload = try #require(buf.pointee.data).advanced(by: 4) - let source = try #require(HardwareVideoDecoder.CustomBlockSource(buf: buf, data: payload)) - #expect(source.dataPtr == UnsafeMutableRawPointer(payload)) - #expect(source.dataPtr != UnsafeMutableRawPointer(buf.pointee.data!)) - source.releaseBuffer() - } - - @Test("CMBlockBuffer with CustomBlockSource holds av_buffer_ref and unrefs on deallocation") - func cmBlockBufferWithCustomBlockSourceLifecycle() throws { - guard let buf = av_buffer_alloc(512) else { - Issue.record("Failed to allocate AVBuffer") - return - } - var bufRef: UnsafeMutablePointer? = buf - defer { - if bufRef != nil { - av_buffer_unref(&bufRef) + guard let packet = trackedPacketAlloc() else { + Issue.record("Failed to allocate AVPacket") + return + } + defer { + var p: UnsafeMutablePointer? = packet + trackedPacketFree(&p) + } + packet.pointee.buf = av_buffer_ref(avBuf) + packet.pointee.data = avBuf.pointee.data + packet.pointee.size = 64 + packet.pointee.pts = 0 + packet.pointee.dts = 0 + + #expect(av_buffer_get_ref_count(avBuf) == 2) + + var refCountWhileHeld = 0 + var heldBlockBuffer: CMBlockBuffer? + decoder.onBlockBufferAllocatedForTesting = { bb in + refCountWhileHeld = Int(av_buffer_get_ref_count(avBuf)) + heldBlockBuffer = bb } - } - #expect(av_buffer_get_ref_count(buf) == 1) + decoder.decode(packet: packet) - do { - let size = 512 - let blockSource = try #require(HardwareVideoDecoder.CustomBlockSource(buf: buf)) - let (blockBuffer, status) = HardwareVideoDecoder.makeBlockBuffer(wrapping: blockSource, size: size) - #expect(status == kCMBlockBufferNoErr) - #expect(blockBuffer != nil) - // Block buffer holds av_buffer_ref - #expect(av_buffer_get_ref_count(buf) == 2) - - // Verify memory block pointer is accessible and matches buf data - var lengthAtOffset = 0 - var totalLength = 0 - var dataPointer: UnsafeMutablePointer? - let accessStatus = CMBlockBufferGetDataPointer( - blockBuffer!, - atOffset: 0, - lengthAtOffsetOut: &lengthAtOffset, - totalLengthOut: &totalLength, - dataPointerOut: &dataPointer - ) - #expect(accessStatus == kCMBlockBufferNoErr) - #expect(totalLength == size) - #expect(dataPointer == UnsafeMutablePointer(OpaquePointer(buf.pointee.data))) - } + // CustomBlockSource took an av_buffer_ref when wrapping into CMBlockBuffer + #expect(refCountWhileHeld >= 3) + #expect(heldBlockBuffer != nil) - // CMBlockBuffer went out of scope: FreeBlock ran takeRetainedValue and unref'd avBuf - #expect(av_buffer_get_ref_count(buf) == 1) - } + // Release caller's packet reference + av_buffer_unref(&packet.pointee.buf) + // Still held by avBufRef and heldBlockBuffer's CustomBlockSource + #expect(av_buffer_get_ref_count(avBuf) == 2) - @Test("CustomBlockSource cleanup on CMBlockBuffer creation failure balances retain") - func customBlockSourceCleanupOnCreationFailure() throws { - guard let buf = av_buffer_alloc(256) else { - Issue.record("Failed to allocate AVBuffer") - return + // Releasing the block buffer decrements the ref count back to 1 + heldBlockBuffer = nil + #expect(av_buffer_get_ref_count(avBuf) == 1) + + // Flush and close decoder to release asynchronous frames and invalidate session + decoder.flush() + decoder.close() + + #expect(av_buffer_get_ref_count(avBuf) == 1) } - var bufRef: UnsafeMutablePointer? = buf - defer { - if bufRef != nil { - av_buffer_unref(&bufRef) + + @Test("HardwareVideoDecoder.decode falls back cleanly to copy when buf == nil") + func hardwareVideoDecoderFallsBackToCopyWhenBufNil() throws { + let decoder = HardwareVideoDecoder() + do { + try decoder.open( + parameterSets: [Self.hevcVPS, Self.hevcSPS, Self.hevcPPS], + timeBase: AVRational(num: 1, den: 90000), + onFrame: { _, _, _ in } + ) + } catch { + try Test.cancel("hardware HEVC session creation failed: \(error)") } - } + defer { decoder.close() } - #expect(av_buffer_get_ref_count(buf) == 1) + decoder.useCustomBlockSource = true - let blockSource = try #require(HardwareVideoDecoder.CustomBlockSource(buf: buf)) - // A payload longer than the block is refused by CoreMedia, which is the failure the decoder's - // own wrapper has to clean up after. - let (blockBuffer, status) = HardwareVideoDecoder.makeBlockBuffer(wrapping: blockSource, size: 256, dataLength: 512) - #expect(status != kCMBlockBufferNoErr) - #expect(blockBuffer == nil) + let rawData = UnsafeMutablePointer.allocate(capacity: 64) + defer { rawData.deallocate() } + rawData.initialize(repeating: 0x80, count: 64) + rawData[0] = 0; rawData[1] = 0; rawData[2] = 0; rawData[3] = 60 + rawData[4] = 0x4E; rawData[5] = 0x01 - // Retain count of AVBuffer drops back to 1 and blockSource is deallocated without leaking - #expect(av_buffer_get_ref_count(buf) == 1) - } + guard let packet = trackedPacketAlloc() else { + Issue.record("Failed to allocate AVPacket") + return + } + defer { + var p: UnsafeMutablePointer? = packet + trackedPacketFree(&p) + } + packet.pointee.buf = nil // Force fallback to copy path + packet.pointee.data = rawData + packet.pointee.size = 64 + packet.pointee.pts = 0 + packet.pointee.dts = 0 - // MARK: - HardwareVideoDecoder.decode with CustomBlockSource vs Fallback Copy - - private static let hevcVPS: [UInt8] = [ - 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x02, 0x20, 0x00, 0x00, 0x03, 0x00, - 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x78, 0x95, 0x94, 0x09 - ] - private static let hevcSPS: [UInt8] = [ - 0x42, 0x01, 0x01, 0x02, 0x20, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, - 0x03, 0x00, 0x00, 0x03, 0x00, 0x78, 0xa0, 0x03, 0xc0, 0x80, 0x11, 0x07, - 0xca, 0xd9, 0x65, 0x65, 0x4a, 0x4c, 0x2f, 0x01, 0x6a, 0x12, 0x20, 0x12, - 0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc0, 0x40 - ] - private static let hevcPPS: [UInt8] = [ - 0x44, 0x01, 0xc0, 0x73, 0xc1, 0x89 - ] - - @Test("CustomBlockSource failable initializer returns nil if data is nil") - func customBlockSourceFailableInit() throws { - guard let buf = av_buffer_alloc(64) else { - Issue.record("Failed to allocate AVBuffer") - return - } - var bufRef: UnsafeMutablePointer? = buf - defer { - if bufRef != nil { - av_buffer_unref(&bufRef) - } - } - - #expect(av_buffer_get_ref_count(buf) == 1) - - // Valid buffer succeeds - let validSource = HardwareVideoDecoder.CustomBlockSource(buf: buf) - #expect(validSource != nil) - validSource?.releaseBuffer() - #expect(av_buffer_get_ref_count(buf) == 1) - - // Buffer with nil data returns nil without leaking av_buffer_ref - let originalData = buf.pointee.data - buf.pointee.data = nil - let nilSource = HardwareVideoDecoder.CustomBlockSource(buf: buf) - #expect(nilSource == nil) - #expect(av_buffer_get_ref_count(buf) == 1) - buf.pointee.data = originalData - } + // Must decode without crashing or throwing + decoder.decode(packet: packet) - @Test("HardwareVideoDecoder.decode with useCustomBlockSource holds av_buffer_ref and decrements on release") - func hardwareVideoDecoderHoldsAndDecrementsBufferRef() throws { - let decoder = HardwareVideoDecoder() - do { - try decoder.open( - parameterSets: [Self.hevcVPS, Self.hevcSPS, Self.hevcPPS], - timeBase: AVRational(num: 1, den: 90000), - onFrame: { _, _, _ in } - ) - } catch { - // Hardware HEVC decompression session might not be supported in some environments: - // reported as skipped, not passed. - try Test.cancel("hardware HEVC session creation failed: \(error)") + decoder.flush() + decoder.close() } - defer { decoder.close() } - decoder.useCustomBlockSource = true + // MARK: - DualLayerVideoDecoder Untouched Packet Delivery - guard let avBuf = av_buffer_alloc(64) else { - Issue.record("Failed to allocate AVBuffer") - return - } - var avBufRef: UnsafeMutablePointer? = avBuf - defer { - if avBufRef != nil { - av_buffer_unref(&avBufRef) + private static func makeAvccPacket(_ nals: [[UInt8]], pts: Int64 = 0) -> UnsafeMutablePointer? { + var total = 0 + for nal in nals { total += 4 + nal.count } + guard let avBuf = av_buffer_alloc(total + Int(AV_INPUT_BUFFER_PADDING_SIZE)) else { return nil } + guard let dst = avBuf.pointee.data else { + var b: UnsafeMutablePointer? = avBuf + av_buffer_unref(&b) + return nil } + var w = 0 + for nal in nals { + let n = nal.count + dst[w + 0] = UInt8((n >> 24) & 0xFF) + dst[w + 1] = UInt8((n >> 16) & 0xFF) + dst[w + 2] = UInt8((n >> 8) & 0xFF) + dst[w + 3] = UInt8(n & 0xFF) + w += 4 + nal.withUnsafeBytes { raw in + if let base = raw.baseAddress { + memcpy(dst + w, base, n) + } + } + w += n + } + memset(dst + total, 0, Int(AV_INPUT_BUFFER_PADDING_SIZE)) + guard let pkt = trackedPacketAlloc() else { + var b: UnsafeMutablePointer? = avBuf + av_buffer_unref(&b) + return nil + } + pkt.pointee.buf = avBuf + pkt.pointee.data = dst + pkt.pointee.size = Int32(total) + pkt.pointee.pts = pts + pkt.pointee.dts = pts + return pkt } - // Initialize with a 4-byte length-prefixed NAL (SEI prefix) - let dataPtr = avBuf.pointee.data! - dataPtr[0] = 0; dataPtr[1] = 0; dataPtr[2] = 0; dataPtr[3] = 60 - dataPtr[4] = 0x4E; dataPtr[5] = 0x01 - for i in 6..<64 { dataPtr[i] = 0x80 } + private static func hevcNAL(type: UInt8, payload: [UInt8]) -> [UInt8] { + [UInt8(type << 1), 0x01] + payload + } - #expect(av_buffer_get_ref_count(avBuf) == 1) + private static func hexToBytes(_ hex: String) -> [UInt8] { + var bytes: [UInt8] = [] + var i = hex.startIndex + while i < hex.endIndex { + let next = hex.index(i, offsetBy: 2) + if let byte = UInt8(hex[i.. [UInt8] { + let felBytes = Self.hexToBytes(DoviFelMathTests.clipARpuHex) + guard let rpu = dovi_parse_unspec62_nalu(felBytes, felBytes.count) else { return [] } + defer { dovi_rpu_free(rpu) } + // libdovi returns a handle even when parsing fails; an error means there is nothing to convert. + guard dovi_rpu_get_error(rpu) == nil else { return [] } + _ = dovi_convert_rpu_with_mode(rpu, 1) + guard let out = dovi_write_unspec62_nalu(rpu) else { return [] } + defer { dovi_data_free(out) } + return [UInt8](UnsafeBufferPointer(start: out.pointee.data, count: out.pointee.len)) + } + + @Test("DualLayerVideoDecoder leaves FEL packet untouched and extracts EL and RPU") + func dualLayerVideoDecoderLeavesFELPacketUntouched() throws { + let primary = CountingFakeDecoder() + let enhancement = CountingFakeDecoder() + let decoder = DualLayerVideoDecoder( + composer: ImmediateFakeComposer(), + primaryDecoder: primary, + enhancementDecoder: enhancement + ) + defer { decoder.close() } - guard let packet = trackedPacketAlloc() else { - Issue.record("Failed to allocate AVPacket") - return - } - defer { - var p: UnsafeMutablePointer? = packet - trackedPacketFree(&p) - } - packet.pointee.buf = av_buffer_ref(avBuf) - packet.pointee.data = avBuf.pointee.data - packet.pointee.size = 64 - packet.pointee.pts = 0 - packet.pointee.dts = 0 + let vps = Self.hevcNAL(type: 32, payload: [0x0c, 0x01, 0xff, 0xff]) + let sps = Self.hevcNAL(type: 33, payload: [0x01, 0x02, 0x20, 0x00]) + let pps = Self.hevcNAL(type: 34, payload: [0xc0, 0x73]) + let bl = Self.hevcNAL(type: 1, payload: [0x11, 0x22, 0x33, 0x44]) + let rpu = Self.hexToBytes(DoviFelMathTests.clipARpuHex) + let el = Self.hevcNAL(type: 63, payload: [0x55, 0x66, 0x77, 0x88]) - #expect(av_buffer_get_ref_count(avBuf) == 2) + guard let pkt = Self.makeAvccPacket([vps, sps, pps, bl, rpu, el], pts: 1000) else { + Issue.record("Failed to build AVCC packet") + return + } + defer { + var p: UnsafeMutablePointer? = pkt + trackedPacketFree(&p) + } - var refCountWhileHeld = 0 - var heldBlockBuffer: CMBlockBuffer? - decoder.onBlockBufferAllocatedForTesting = { bb in - refCountWhileHeld = Int(av_buffer_get_ref_count(avBuf)) - heldBlockBuffer = bb - } + let originalData = pkt.pointee.data + let originalSize = pkt.pointee.size + let originalBuf = pkt.pointee.buf - decoder.decode(packet: packet) + decoder.decode(packet: pkt) - // CustomBlockSource took an av_buffer_ref when wrapping into CMBlockBuffer - #expect(refCountWhileHeld >= 3) - #expect(heldBlockBuffer != nil) + // Packet pointer, size, and buffer must be COMPLETELY UNTOUCHED + #expect(pkt.pointee.data == originalData) + #expect(pkt.pointee.size == originalSize) + #expect(pkt.pointee.buf == originalBuf) - // Release caller's packet reference - av_buffer_unref(&packet.pointee.buf) - // Still held by avBufRef and heldBlockBuffer's CustomBlockSource - #expect(av_buffer_get_ref_count(avBuf) == 2) + // Classified as FEL + #expect(decoder.streamClassificationForTesting == .fel) - // Releasing the block buffer decrements the ref count back to 1 - heldBlockBuffer = nil - #expect(av_buffer_get_ref_count(avBuf) == 1) + // Primary received the base layer + #expect(primary.decodeCount == 1) - // Flush and close decoder to release asynchronous frames and invalidate session - decoder.flush() - decoder.close() + // Enhancement decoder was engaged and received the extracted EL packet + #expect(enhancement.decodeCount == 1) - #expect(av_buffer_get_ref_count(avBuf) == 1) - } + // Display metadata RPU was extracted and cached + #expect(decoder.stateSnapshotForTesting.dmRpuCacheCount == 1) + // RPU params were extracted and cached + #expect(decoder.stateSnapshotForTesting.rpuCacheCount == 1) + } - @Test("HardwareVideoDecoder.decode falls back cleanly to copy when buf == nil") - func hardwareVideoDecoderFallsBackToCopyWhenBufNil() throws { - let decoder = HardwareVideoDecoder() - do { - try decoder.open( - parameterSets: [Self.hevcVPS, Self.hevcSPS, Self.hevcPPS], - timeBase: AVRational(num: 1, den: 90000), - onFrame: { _, _, _ in } + @Test("DualLayerVideoDecoder leaves MEL packet untouched and extracts RPU without engaging EL decoder") + func dualLayerVideoDecoderLeavesMELPacketUntouched() throws { + let primary = CountingFakeDecoder() + let enhancement = CountingFakeDecoder() + let decoder = DualLayerVideoDecoder( + composer: ImmediateFakeComposer(), + primaryDecoder: primary, + enhancementDecoder: enhancement ) - } catch { - try Test.cancel("hardware HEVC session creation failed: \(error)") - } - defer { decoder.close() } + defer { decoder.close() } + + let vps = Self.hevcNAL(type: 32, payload: [0x0c, 0x01, 0xff, 0xff]) + let sps = Self.hevcNAL(type: 33, payload: [0x01, 0x02, 0x20, 0x00]) + let pps = Self.hevcNAL(type: 34, payload: [0xc0, 0x73]) + let bl = Self.hevcNAL(type: 1, payload: [0x11, 0x22, 0x33, 0x44]) + let melRpu = Self.makeMelRpuBytes() + #expect(!melRpu.isEmpty) + let el = Self.hevcNAL(type: 63, payload: [0x55, 0x66]) + + guard let pkt = Self.makeAvccPacket([vps, sps, pps, bl, melRpu, el], pts: 2000) else { + Issue.record("Failed to build AVCC packet") + return + } + defer { + var p: UnsafeMutablePointer? = pkt + trackedPacketFree(&p) + } - decoder.useCustomBlockSource = true + let originalData = pkt.pointee.data + let originalSize = pkt.pointee.size + let originalBuf = pkt.pointee.buf - let rawData = UnsafeMutablePointer.allocate(capacity: 64) - defer { rawData.deallocate() } - rawData.initialize(repeating: 0x80, count: 64) - rawData[0] = 0; rawData[1] = 0; rawData[2] = 0; rawData[3] = 60 - rawData[4] = 0x4E; rawData[5] = 0x01 + decoder.decode(packet: pkt) - guard let packet = trackedPacketAlloc() else { - Issue.record("Failed to allocate AVPacket") - return - } - defer { - var p: UnsafeMutablePointer? = packet - trackedPacketFree(&p) - } - packet.pointee.buf = nil // Force fallback to copy path - packet.pointee.data = rawData - packet.pointee.size = 64 - packet.pointee.pts = 0 - packet.pointee.dts = 0 + // Base-layer packet pointer, size, and buffer must be COMPLETELY UNTOUCHED + #expect(pkt.pointee.data == originalData) + #expect(pkt.pointee.size == originalSize) + #expect(pkt.pointee.buf == originalBuf) - // Must decode without crashing or throwing - decoder.decode(packet: packet) + // Classified as MEL + #expect(decoder.streamClassificationForTesting == .mel) - decoder.flush() - decoder.close() - } + // Primary received the base layer + #expect(primary.decodeCount == 1) - // MARK: - DualLayerVideoDecoder Untouched Packet Delivery - - private static func makeAvccPacket(_ nals: [[UInt8]], pts: Int64 = 0) -> UnsafeMutablePointer? { - var total = 0 - for nal in nals { total += 4 + nal.count } - guard let avBuf = av_buffer_alloc(total + Int(AV_INPUT_BUFFER_PADDING_SIZE)) else { return nil } - guard let dst = avBuf.pointee.data else { - var b: UnsafeMutablePointer? = avBuf - av_buffer_unref(&b) - return nil - } - var w = 0 - for nal in nals { - let n = nal.count - dst[w + 0] = UInt8((n >> 24) & 0xFF) - dst[w + 1] = UInt8((n >> 16) & 0xFF) - dst[w + 2] = UInt8((n >> 8) & 0xFF) - dst[w + 3] = UInt8(n & 0xFF) - w += 4 - nal.withUnsafeBytes { raw in - if let base = raw.baseAddress { - memcpy(dst + w, base, n) - } - } - w += n - } - memset(dst + total, 0, Int(AV_INPUT_BUFFER_PADDING_SIZE)) - guard let pkt = trackedPacketAlloc() else { - var b: UnsafeMutablePointer? = avBuf - av_buffer_unref(&b) - return nil + // Enhancement decoder was NOT engaged for MEL + #expect(enhancement.decodeCount == 0) + + // Display metadata RPU was extracted and cached + #expect(decoder.stateSnapshotForTesting.dmRpuCacheCount == 1) } - pkt.pointee.buf = avBuf - pkt.pointee.data = dst - pkt.pointee.size = Int32(total) - pkt.pointee.pts = pts - pkt.pointee.dts = pts - return pkt - } - private static func hevcNAL(type: UInt8, payload: [UInt8]) -> [UInt8] { - [UInt8(type << 1), 0x01] + payload - } + // MARK: - Per-Frame Logging & 1 Hz Summary - private static func hexToBytes(_ hex: String) -> [UInt8] { - var bytes: [UInt8] = [] - var i = hex.startIndex - while i < hex.endIndex { - let next = hex.index(i, offsetBy: 2) - if let byte = UInt8(hex[i.. [UInt8] { - let felBytes = Self.hexToBytes(DoviFelMathTests.clipARpuHex) - guard let rpu = dovi_parse_unspec62_nalu(felBytes, felBytes.count) else { return [] } - defer { dovi_rpu_free(rpu) } - // libdovi returns a handle even when parsing fails; an error means there is nothing to convert. - guard dovi_rpu_get_error(rpu) == nil else { return [] } - _ = dovi_convert_rpu_with_mode(rpu, 1) - guard let out = dovi_write_unspec62_nalu(rpu) else { return [] } - defer { dovi_data_free(out) } - return [UInt8](UnsafeBufferPointer(start: out.pointee.data, count: out.pointee.len)) - } + var emittedLines: [String] = [] + let logLock = NSLock() + EngineLog.handler = { line in + logLock.lock() + emittedLines.append(line) + logLock.unlock() + } - @Test("DualLayerVideoDecoder leaves FEL packet untouched and extracts EL and RPU") - func dualLayerVideoDecoderLeavesFELPacketUntouched() throws { - let primary = CountingFakeDecoder() - let enhancement = CountingFakeDecoder() - let decoder = DualLayerVideoDecoder( - composer: ImmediateFakeComposer(), - primaryDecoder: primary, - enhancementDecoder: enhancement - ) - defer { decoder.close() } - - let vps = Self.hevcNAL(type: 32, payload: [0x0c, 0x01, 0xff, 0xff]) - let sps = Self.hevcNAL(type: 33, payload: [0x01, 0x02, 0x20, 0x00]) - let pps = Self.hevcNAL(type: 34, payload: [0xc0, 0x73]) - let bl = Self.hevcNAL(type: 1, payload: [0x11, 0x22, 0x33, 0x44]) - let rpu = Self.hexToBytes(DoviFelMathTests.clipARpuHex) - let el = Self.hevcNAL(type: 63, payload: [0x55, 0x66, 0x77, 0x88]) - - guard let pkt = Self.makeAvccPacket([vps, sps, pps, bl, rpu, el], pts: 1000) else { - Issue.record("Failed to build AVCC packet") - return - } - defer { - var p: UnsafeMutablePointer? = pkt - trackedPacketFree(&p) - } - - let originalData = pkt.pointee.data - let originalSize = pkt.pointee.size - let originalBuf = pkt.pointee.buf - - decoder.decode(packet: pkt) - - // Packet pointer, size, and buffer must be COMPLETELY UNTOUCHED - #expect(pkt.pointee.data == originalData) - #expect(pkt.pointee.size == originalSize) - #expect(pkt.pointee.buf == originalBuf) - - // Classified as FEL - #expect(decoder.streamClassificationForTesting == .fel) - - // Primary received the base layer - #expect(primary.decodeCount == 1) - - // Enhancement decoder was engaged and received the extracted EL packet - #expect(enhancement.decodeCount == 1) - - // Display metadata RPU was extracted and cached - #expect(decoder.stateSnapshotForTesting.dmRpuCacheCount == 1) - // RPU params were extracted and cached - #expect(decoder.stateSnapshotForTesting.rpuCacheCount == 1) - } + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) + defer { decoder.close() } - @Test("DualLayerVideoDecoder leaves MEL packet untouched and extracts RPU without engaging EL decoder") - func dualLayerVideoDecoderLeavesMELPacketUntouched() throws { - let primary = CountingFakeDecoder() - let enhancement = CountingFakeDecoder() - let decoder = DualLayerVideoDecoder( - composer: ImmediateFakeComposer(), - primaryDecoder: primary, - enhancementDecoder: enhancement - ) - defer { decoder.close() } + // Send a base-layer frame with no EL / no RPU (will bypass) + let buf1 = try #require(Self.baseLayer()) + decoder.handleBaseLayerFrame(baseBuffer: buf1, pts: Self.pts(1), hdr10Data: nil) - let vps = Self.hevcNAL(type: 32, payload: [0x0c, 0x01, 0xff, 0xff]) - let sps = Self.hevcNAL(type: 33, payload: [0x01, 0x02, 0x20, 0x00]) - let pps = Self.hevcNAL(type: 34, payload: [0xc0, 0x73]) - let bl = Self.hevcNAL(type: 1, payload: [0x11, 0x22, 0x33, 0x44]) - let melRpu = Self.makeMelRpuBytes() - #expect(!melRpu.isEmpty) - let el = Self.hevcNAL(type: 63, payload: [0x55, 0x66]) + // Simulate FEL stream with EL decoder active: + decoder.primeForTesting(enhancementLayer: true) - guard let pkt = Self.makeAvccPacket([vps, sps, pps, bl, melRpu, el], pts: 2000) else { - Issue.record("Failed to build AVCC packet") - return - } - defer { - var p: UnsafeMutablePointer? = pkt - trackedPacketFree(&p) - } + // Send unmatched BL (will queue BL) + let buf2 = try #require(Self.baseLayer()) + decoder.handleBaseLayerFrame(baseBuffer: buf2, pts: Self.pts(2), hdr10Data: nil) - let originalData = pkt.pointee.data - let originalSize = pkt.pointee.size - let originalBuf = pkt.pointee.buf + // Send unmatched EL (will queue EL) + let buf3 = try #require(Self.enhancementLayer()) + decoder.handleEnhancementLayerFrame(elBuffer: buf3, pts: Self.pts(3)) - decoder.decode(packet: pkt) + logLock.lock() + let captured = emittedLines + logLock.unlock() - // Base-layer packet pointer, size, and buffer must be COMPLETELY UNTOUCHED - #expect(pkt.pointee.data == originalData) - #expect(pkt.pointee.size == originalSize) - #expect(pkt.pointee.buf == originalBuf) + let forbiddenPrefixes = [ + "[DualLayerVideoDecoder] queued base layer", + "[DualLayerVideoDecoder] queued enhancement layer", + "[DualLayerVideoDecoder] bypass,", + "[DualLayerVideoDecoder] dropped stale enhancement layer", + "[DualLayerVideoDecoder] pruned enhancement layer", + "[DualLayerVideoDecoder] compose failed" + ] + for prefix in forbiddenPrefixes { + let found = captured.filter { $0.contains(prefix) } + #expect(found.isEmpty, "Found forbidden per-frame log at info level: \(found)") + } + } - // Classified as MEL - #expect(decoder.streamClassificationForTesting == .mel) + @Test("1 Hz summary aggregates and emits expected fields, then resets interval") + func fel14OneHzSummaryAggregationAndFormat() throws { + var summaryLines: [String] = [] + let logLock = NSLock() + var summaryLevels: [EngineLog.Level] = [] + let composer = ImmediateFakeComposer() + let decoder = DualLayerVideoDecoder(composer: composer) + defer { decoder.close() } + + var currentTime: TimeInterval = 10.0 + decoder.summaryTimestampProviderForTesting = { currentTime } + decoder.summarySinkForTesting = { line, level in + logLock.lock() + summaryLines.append(line) + summaryLevels.append(level) + logLock.unlock() + } - // Primary received the base layer - #expect(primary.decodeCount == 1) + decoder.primeForTesting(enhancementLayer: true) - // Enhancement decoder was NOT engaged for MEL - #expect(enhancement.decodeCount == 0) + let baseBuf = try #require(Self.baseLayer()) + let elBuf = try #require(Self.enhancementLayer()) - // Display metadata RPU was extracted and cached - #expect(decoder.stateSnapshotForTesting.dmRpuCacheCount == 1) - } + // Inject cached RPU and pair a frame at t = 10.0 + decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - // MARK: - Per-Frame Logging & 1 Hz Summary + // Base layer arrives at t = 10.0: sets lastSummaryTimestamp = 10.0 + decoder.handleBaseLayerFrame(baseBuffer: baseBuf, pts: Self.pts(1), hdr10Data: nil) - @Test("per-frame logs (queue, bypass, drop) are NOT emitted at default info level") - func fel14PerFrameLogsNotEmittedAtDefaultInfoLevel() throws { - let prevHandler = EngineLog.handler - defer { EngineLog.handler = prevHandler } + // Enhancement layer arrives at t = 10.0: pairs and composes + decoder.handleEnhancementLayerFrame(elBuffer: elBuf, pts: Self.pts(1)) - var emittedLines: [String] = [] - let logLock = NSLock() - EngineLog.handler = { line in + // Advance by 0.5s to 10.5: interval (1.0s) has not elapsed yet + currentTime = 10.5 + decoder.maybeEmitSummary() logLock.lock() - emittedLines.append(line) + #expect(summaryLines.isEmpty) logLock.unlock() - } - let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) - defer { decoder.close() } + // Advance by 1.1s to 11.1 (currentTime >= 10.0 + 1.0): summary emits! + currentTime = 11.1 + decoder.maybeEmitSummary() + logLock.lock() + #expect(summaryLines.count == 1) + let summary = summaryLines.first ?? "" + logLock.unlock() - // Send a base-layer frame with no EL / no RPU (will bypass) - let buf1 = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: buf1, pts: Self.pts(1), hdr10Data: nil) + #expect(summary.contains("[DualLayerVideoDecoder] summary:")) + #expect(summary.contains("composed=1")) + #expect(summary.contains("bypassNoELInAU=0")) + #expect(summary.contains("bypassDeadline=0")) + #expect(summary.contains("bypassHeldWall=0")) + #expect(summary.contains("bypassBackstop=0")) + #expect(summary.contains("bypassNoELDecoder=0")) + #expect(summary.contains("bypassEndOfStream=0")) + #expect(summary.contains("bypassNoRPU=0")) + #expect(summary.contains("staleELDropped=0")) + #expect(summary.contains("peakGpuSlots=")) + #expect(summary.contains("poolBuffers=")) + #expect(summary.contains("deliveryPendingPeak=")) + #expect(summary.contains("meanGpuMs=")) + // A healthy second is a trace: verbose reaches OSLog's debug level only, never the host handler. + logLock.lock() + #expect(summaryLevels == [.verbose]) + logLock.unlock() - // Simulate FEL stream with EL decoder active: - decoder.primeForTesting(enhancementLayer: true) + // Next call immediately at 11.2 does not emit again until another second passes + currentTime = 11.2 + decoder.maybeEmitSummary() + logLock.lock() + #expect(summaryLines.count == 1) + logLock.unlock() - // Send unmatched BL (will queue BL) - let buf2 = try #require(Self.baseLayer()) - decoder.handleBaseLayerFrame(baseBuffer: buf2, pts: Self.pts(2), hdr10Data: nil) + // After another 1.0s at 12.2, emits interval 2 with composed=0 + currentTime = 12.2 + decoder.maybeEmitSummary() + logLock.lock() + #expect(summaryLines.count == 2) + let summary2 = summaryLines.last ?? "" + logLock.unlock() + #expect(summary2.contains("composed=0")) + } + + @Test("a 1 Hz summary that counts a compose failure goes out at the default level") + func summaryWithComposeFailureIsInfo() throws { + var levels: [EngineLog.Level] = [] + var lines: [String] = [] + let logLock = NSLock() + let decoder = DualLayerVideoDecoder(composer: FailingFakeComposer()) + defer { decoder.close() } + + var currentTime: TimeInterval = 30.0 + decoder.summaryTimestampProviderForTesting = { currentTime } + decoder.summarySinkForTesting = { line, level in + logLock.lock() + lines.append(line) + levels.append(level) + logLock.unlock() + } + decoder.primeForTesting(enhancementLayer: true) - // Send unmatched EL (will queue EL) - let buf3 = try #require(Self.enhancementLayer()) - decoder.handleEnhancementLayerFrame(elBuffer: buf3, pts: Self.pts(3)) + let baseBuf = try #require(Self.baseLayer()) + let elBuf = try #require(Self.enhancementLayer()) + decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) + decoder.handleBaseLayerFrame(baseBuffer: baseBuf, pts: Self.pts(1), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: elBuf, pts: Self.pts(1)) - logLock.lock() - let captured = emittedLines - logLock.unlock() + currentTime = 31.1 + decoder.maybeEmitSummary() + currentTime = 32.2 + decoder.maybeEmitSummary() - let forbiddenPrefixes = [ - "[DualLayerVideoDecoder] queued base layer", - "[DualLayerVideoDecoder] queued enhancement layer", - "[DualLayerVideoDecoder] bypass,", - "[DualLayerVideoDecoder] dropped stale enhancement layer", - "[DualLayerVideoDecoder] pruned enhancement layer", - "[DualLayerVideoDecoder] compose failed" - ] - for prefix in forbiddenPrefixes { - let found = captured.filter { $0.contains(prefix) } - #expect(found.isEmpty, "Found forbidden per-frame log at info level: \(found)") - } - } + logLock.lock() + defer { logLock.unlock() } + #expect(lines.count == 2) + #expect(lines.first?.contains("composeFailures=1") == true) + #expect(levels == [.info, .verbose], "only the interval with the failure reaches the host handler") + } + + @Test("the session totals of the bypass counters go out at info level, at most every 10 s and only when a count moved") + func totalsLineIsRateLimitedAndSendsOnChange() throws { + var totals: [String] = [] + let logLock = NSLock() + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) + defer { decoder.close() } + + var currentTime: TimeInterval = 40.0 + decoder.summaryTimestampProviderForTesting = { currentTime } + decoder.totalsSinkForTesting = { line in + logLock.lock() + totals.append(line) + logLock.unlock() + } + decoder.primeForTesting(enhancementLayer: true) + + func composeFrame(_ n: Int64) throws { + let baseBuf = try #require(Self.baseLayer()) + let elBuf = try #require(Self.enhancementLayer()) + decoder.seedRPUForTesting(pts: Self.pts(n), params: Self.params(), displayMetadataNAL: nil) + decoder.handleBaseLayerFrame(baseBuffer: baseBuf, pts: Self.pts(n), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: elBuf, pts: Self.pts(n)) + } + func count() -> Int { + logLock.lock() + defer { logLock.unlock() } + return totals.count + } - @Test("1 Hz summary aggregates and emits expected fields, then resets interval") - func fel14OneHzSummaryAggregationAndFormat() throws { - var summaryLines: [String] = [] - let logLock = NSLock() - var summaryLevels: [EngineLog.Level] = [] - let composer = ImmediateFakeComposer() - let decoder = DualLayerVideoDecoder(composer: composer) - defer { decoder.close() } - - var currentTime: TimeInterval = 10.0 - decoder.summaryTimestampProviderForTesting = { currentTime } - decoder.summarySinkForTesting = { line, level in + // The first call only records the baseline; the first interval with a composed frame sends a line. + try composeFrame(1) + decoder.maybeEmitSummary() + currentTime = 41.1 + decoder.maybeEmitSummary() + #expect(count() == 1) logLock.lock() - summaryLines.append(line) - summaryLevels.append(level) + #expect(totals.first == DualLayerVideoDecoder.totalsLine(composed: 1, bypassDeadline: 0, bypassHeldWall: 0, bypassBackstop: 0)) logLock.unlock() - } - - decoder.primeForTesting(enhancementLayer: true) - - let baseBuf = try #require(Self.baseLayer()) - let elBuf = try #require(Self.enhancementLayer()) - - // Inject cached RPU and pair a frame at t = 10.0 - decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - - // Base layer arrives at t = 10.0: sets lastSummaryTimestamp = 10.0 - decoder.handleBaseLayerFrame(baseBuffer: baseBuf, pts: Self.pts(1), hdr10Data: nil) - - // Enhancement layer arrives at t = 10.0: pairs and composes - decoder.handleEnhancementLayerFrame(elBuffer: elBuf, pts: Self.pts(1)) - - // Advance by 0.5s to 10.5: interval (1.0s) has not elapsed yet - currentTime = 10.5 - decoder.maybeEmitSummary() - logLock.lock() - #expect(summaryLines.isEmpty) - logLock.unlock() - - // Advance by 1.1s to 11.1 (currentTime >= 10.0 + 1.0): summary emits! - currentTime = 11.1 - decoder.maybeEmitSummary() - logLock.lock() - #expect(summaryLines.count == 1) - let summary = summaryLines.first ?? "" - logLock.unlock() - - #expect(summary.contains("[DualLayerVideoDecoder] summary:")) - #expect(summary.contains("composed=1")) - #expect(summary.contains("bypassNoEL=0")) - #expect(summary.contains("bypassNoRPU=0")) - #expect(summary.contains("staleELDropped=0")) - #expect(summary.contains("peakGpuSlots=")) - #expect(summary.contains("poolBuffers=")) - #expect(summary.contains("deliveryPendingPeak=")) - #expect(summary.contains("meanGpuMs=")) - // A healthy second is a trace: verbose reaches OSLog's debug level only, never the host handler. - logLock.lock() - #expect(summaryLevels == [.verbose]) - logLock.unlock() - - // Next call immediately at 11.2 does not emit again until another second passes - currentTime = 11.2 - decoder.maybeEmitSummary() - logLock.lock() - #expect(summaryLines.count == 1) - logLock.unlock() - - // After another 1.0s at 12.2, emits interval 2 with composed=0 - currentTime = 12.2 - decoder.maybeEmitSummary() - logLock.lock() - #expect(summaryLines.count == 2) - let summary2 = summaryLines.last ?? "" - logLock.unlock() - #expect(summary2.contains("composed=0")) - } - @Test("a 1 Hz summary that counts a compose failure goes out at the default level") - func summaryWithComposeFailureIsInfo() throws { - var levels: [EngineLog.Level] = [] - var lines: [String] = [] - let logLock = NSLock() - let decoder = DualLayerVideoDecoder(composer: FailingFakeComposer()) - defer { decoder.close() } - - var currentTime: TimeInterval = 30.0 - decoder.summaryTimestampProviderForTesting = { currentTime } - decoder.summarySinkForTesting = { line, level in + // Nothing moved in the next interval: no line, even though the interval summary goes out. + currentTime = 42.2 + decoder.maybeEmitSummary() + #expect(count() == 1) + + // A count moved, but under 10 s since the last totals line: held back. + try composeFrame(2) + currentTime = 43.3 + decoder.maybeEmitSummary() + #expect(count() == 1) + + // Still moved, and 10 s have passed: the totals go out, cumulative over the held-back interval. + currentTime = 51.2 + decoder.maybeEmitSummary() + #expect(count() == 2) logLock.lock() - lines.append(line) - levels.append(level) + #expect(totals.last == DualLayerVideoDecoder.totalsLine(composed: 2, bypassDeadline: 0, bypassHeldWall: 0, bypassBackstop: 0)) logLock.unlock() } - decoder.primeForTesting(enhancementLayer: true) - - let baseBuf = try #require(Self.baseLayer()) - let elBuf = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - decoder.handleBaseLayerFrame(baseBuffer: baseBuf, pts: Self.pts(1), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: elBuf, pts: Self.pts(1)) - currentTime = 31.1 - decoder.maybeEmitSummary() - currentTime = 32.2 - decoder.maybeEmitSummary() + @Test("a flush folds the interval in progress into the session totals") + func flushKeepsTheLastIntervalInTheTotals() throws { + var totals: [String] = [] + let logLock = NSLock() + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) + defer { decoder.close() } - logLock.lock() - defer { logLock.unlock() } - #expect(lines.count == 2) - #expect(lines.first?.contains("composeFailures=1") == true) - #expect(levels == [.info, .verbose], "only the interval with the failure reaches the host handler") - } + var currentTime: TimeInterval = 60.0 + decoder.summaryTimestampProviderForTesting = { currentTime } + decoder.totalsSinkForTesting = { line in + logLock.lock() + totals.append(line) + logLock.unlock() + } + decoder.primeForTesting(enhancementLayer: true) - @Test("flush and close reset 1 Hz summary counters") - func fel14FlushAndCloseResetSummaryCounters() throws { - var summaryLines: [String] = [] - let logLock = NSLock() + let baseBuf = try #require(Self.baseLayer()) + let elBuf = try #require(Self.enhancementLayer()) + decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) + decoder.handleBaseLayerFrame(baseBuffer: baseBuf, pts: Self.pts(1), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: elBuf, pts: Self.pts(1)) - let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) - defer { decoder.close() } + // The frame is composed but no summary has run yet: a seek arrives first. + decoder.flush() - var currentTime: TimeInterval = 20.0 - decoder.summaryTimestampProviderForTesting = { currentTime } - decoder.summarySinkForTesting = { line, _ in + // The first call after the flush only records the baseline, the next one emits. + decoder.maybeEmitSummary() + currentTime = 61.1 + decoder.maybeEmitSummary() logLock.lock() - summaryLines.append(line) - logLock.unlock() + defer { logLock.unlock() } + #expect(totals.last == DualLayerVideoDecoder.totalsLine(composed: 1, bypassDeadline: 0, bypassHeldWall: 0, bypassBackstop: 0)) } - decoder.primeForTesting(enhancementLayer: true) - let baseBuf = try #require(Self.baseLayer()) - let elBuf = try #require(Self.enhancementLayer()) - decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) - decoder.handleBaseLayerFrame(baseBuffer: baseBuf, pts: Self.pts(1), hdr10Data: nil) - decoder.handleEnhancementLayerFrame(elBuffer: elBuf, pts: Self.pts(1)) + @Test("the totals line names the counters a device run judges the deadline bypass by") + func totalsLineFormat() { + let line = DualLayerVideoDecoder.totalsLine(composed: 120, bypassDeadline: 3, bypassHeldWall: 2, bypassBackstop: 1) + #expect(line == "[DualLayerVideoDecoder] totals: composed=120, bypassDeadline=3, bypassHeldWall=2, bypassBackstop=1") + } - // Flush resets counters and timestamp - decoder.flush() + @Test("flush and close reset 1 Hz summary counters") + func fel14FlushAndCloseResetSummaryCounters() throws { + var summaryLines: [String] = [] + let logLock = NSLock() - // Advance time to 21.5 and call maybeEmitSummary: since flush reset lastSummaryTimestamp to 0, - // this call merely records the new baseline and does NOT emit - currentTime = 21.5 - decoder.maybeEmitSummary() + let decoder = DualLayerVideoDecoder(composer: ImmediateFakeComposer()) + defer { decoder.close() } - logLock.lock() - #expect(summaryLines.isEmpty) - logLock.unlock() + var currentTime: TimeInterval = 20.0 + decoder.summaryTimestampProviderForTesting = { currentTime } + decoder.summarySinkForTesting = { line, _ in + logLock.lock() + summaryLines.append(line) + logLock.unlock() + } + decoder.primeForTesting(enhancementLayer: true) - // Advance by 1.1s to 22.6: summary emits with 0 composed frames - currentTime = 22.6 - decoder.maybeEmitSummary() + let baseBuf = try #require(Self.baseLayer()) + let elBuf = try #require(Self.enhancementLayer()) + decoder.seedRPUForTesting(pts: Self.pts(1), params: Self.params(), displayMetadataNAL: nil) + decoder.handleBaseLayerFrame(baseBuffer: baseBuf, pts: Self.pts(1), hdr10Data: nil) + decoder.handleEnhancementLayerFrame(elBuffer: elBuf, pts: Self.pts(1)) - logLock.lock() - #expect(summaryLines.count == 1) - let summary = summaryLines.first ?? "" - logLock.unlock() - #expect(summary.contains("composed=0")) - } + // Flush resets counters and timestamp + decoder.flush() - // MARK: - Memory Limits & Queue Bounds + // Advance time to 21.5 and call maybeEmitSummary: since flush reset lastSummaryTimestamp to 0, + // this call merely records the new baseline and does NOT emit + currentTime = 21.5 + decoder.maybeEmitSummary() - @Test("output pool enforces its allocation threshold") - func fel18OutputPoolEnforcesAllocationThreshold() throws { - guard let composer = DoviFelComposer() else { - Issue.record("Failed to create DoviFelComposer") - return - } - guard let pool = composer.ensurePoolForTesting(width: 256, height: 256) else { - Issue.record("Failed to create pixel buffer pool") - return - } + logLock.lock() + #expect(summaryLines.isEmpty) + logLock.unlock() + + // Advance by 1.1s to 22.6: summary emits with 0 composed frames + currentTime = 22.6 + decoder.maybeEmitSummary() - var heldBuffers: [CVPixelBuffer] = [] - // Allocate up to the threshold - for _ in 0..? = melPkt + trackedPacketFree(&optMel) } - decoder.decode(packet: melPkt) - var optMel: UnsafeMutablePointer? = melPkt - trackedPacketFree(&optMel) - } - // Classification must be MEL, not FEL - #expect(decoder.streamClassification == .mel) + // Classification must be MEL, not FEL + #expect(decoder.streamClassification == .mel) - #expect(PacketBalanceTracker.alive - aliveBefore < iterations / 2) + #expect(PacketBalanceTracker.alive - aliveBefore < iterations / 2) + } } } #endif diff --git a/Tests/AetherEngineTests/FrameSideDataRingTests.swift b/Tests/AetherEngineTests/FrameSideDataRingTests.swift index 709f65743..2896dcad2 100644 --- a/Tests/AetherEngineTests/FrameSideDataRingTests.swift +++ b/Tests/AetherEngineTests/FrameSideDataRingTests.swift @@ -2,6 +2,7 @@ #if DualLayerDolbyVision import Testing import Foundation +import AetherLibavutil @testable import AetherEngine @Suite("FrameSideDataRing unit tests") @@ -151,5 +152,30 @@ struct FrameSideDataRingTests { #expect(ring.dmNALCount == 0) #expect(ring.find(key: 1_000) == nil) } + + @Test("keys from a 1/1000 time base are rescaled to 90 kHz before they reach the ring, so frames do not share side data") + func millisecondTimeBaseKeysAreCanonical() { + // 24 fps in Matroska's 1/1000: frames at 0, 41.7, 83.3, 125 ms round to these raw ticks. + let tb = AVRational(num: 1, den: 1000) + let raw: [Int64] = [0, 42, 83, 125] + let keys = raw.map { DualLayerVideoDecoder.canonicalKey(rawPTS: $0, timeBase: tb) } + // 90 ticks per millisecond + #expect(keys == [0, 3_780, 7_470, 11_250]) + // Raw millisecond values would sit inside one ±300 window of each other's neighbours' scale + // (42 ms apart < 300); the canonical keys are 3 000+ ticks apart, outside the window. + #expect(FrameSideDataRing.isWithinJitter(raw[0], raw[1])) + #expect(!FrameSideDataRing.isWithinJitter(keys[0], keys[1])) + + var ring = FrameSideDataRing() + for (i, k) in keys.enumerated() { + ring.insert(key: k, params: DoviMetalParams(), dmNAL: [UInt8(i)]) + } + for (i, k) in keys.enumerated() { + #expect(ring.find(key: k)?.dmNAL == [UInt8(i)], "frame \(i) resolves to its own side data") + } + // A timestamp 1 ms off (the demuxer's rounding) still finds its own frame, not a neighbour. + let off = DualLayerVideoDecoder.canonicalKey(rawPTS: 84, timeBase: tb) + #expect(ring.find(key: off)?.key == keys[2]) + } } #endif diff --git a/Tests/AetherEngineTests/OrderedDeliveryTests.swift b/Tests/AetherEngineTests/OrderedDeliveryTests.swift index 3d24a4193..a4d2d971c 100644 --- a/Tests/AetherEngineTests/OrderedDeliveryTests.swift +++ b/Tests/AetherEngineTests/OrderedDeliveryTests.swift @@ -59,6 +59,31 @@ struct OrderedDeliveryTests { #expect(recorder.snapshot == [0, 1, 2, 3, 4]) } + @Test("a skipped ticket keeps its place, emits nothing and does not hold up the frames behind it") + func skippedTicketEmitsNothing() async throws { + let buffer = try #require(Self.makeBuffer()) + let delivery = OrderedDelivery(maxFramesInFlight: 3, maxLayerSkew: 3, label: "test.ordered-delivery.skip") + let recorder = Recorder() + delivery.setOnFrame { _, pts, _ in recorder.record(pts) } + + delivery.fulfil(ticket: 2, epoch: 0, Self.frame(buffer, tag: 2)) + delivery.skip(ticket: 1, epoch: 0) + delivery.fulfil(ticket: 0, epoch: 0, Self.frame(buffer, tag: 0)) + delivery.fulfil(ticket: 3, epoch: 0, Self.frame(buffer, tag: 3)) + + try await waitFor { recorder.count == 3 } + #expect(recorder.snapshot == [0, 2, 3]) + #expect(delivery.nextToEmitForTesting == 4) + #expect(delivery.pendingCountForTesting == 0) + + // A skip from before a reset is dropped like any other late delivery. + delivery.reset(epoch: 1, nextTicket: 10) + delivery.skip(ticket: 10, epoch: 0) + delivery.fulfil(ticket: 10, epoch: 1, Self.frame(buffer, tag: 10)) + try await waitFor { recorder.count == 4 } + #expect(recorder.snapshot == [0, 2, 3, 10]) + } + @Test("bypass-shaped and compose-shaped fulfil calls interleave through the same order, indistinguishably") func bypassAndComposeShareOneOrder() async throws { let buffer = try #require(Self.makeBuffer()) diff --git a/Tests/AetherEngineTests/PresentationFloorTests.swift b/Tests/AetherEngineTests/PresentationFloorTests.swift index c2cbb9612..264d0e0e8 100644 --- a/Tests/AetherEngineTests/PresentationFloorTests.swift +++ b/Tests/AetherEngineTests/PresentationFloorTests.swift @@ -243,6 +243,55 @@ struct ArrivalOrderHandoverTests { #expect(handed.seconds.allSatisfy { $0 >= Self.time(100).seconds }) #expect(handed.seconds.count == 44) #expect(renderer.framesBelowFloor == 0) + // The anchor at 96 is refused by the seek threshold itself, and the three B-frames below the + // target that follow the first frame past it by the floor; all four are counted, so every one + // of the 48 frames fed in is either handed over or counted. + #expect(renderer.arrivalSkippedFramesDropped == 4) + #expect(renderer.arrivalSkippedFramesDropped + handed.seconds.count == 48) + #expect(renderer.takeCadence().lostBeforeLayer == 0, "a seek's discard is a decision, not a loss") + } + + @Test("The first skipped frame of each seek is logged once, not once per renderer") + @MainActor + func firstSkipLogsOncePerSeek() { + let capture = EngineLogCapture() + defer { capture.end() } + let renderer = SampleBufferRenderer() + renderer.setArrivalOrderHandover(frameRate: 24) + let pixels = Self.makePixelBuffer() + // Targets no other test uses, matched on the threshold in the line itself: the capture sees + // every test that logs at the same time. + let first = 300 // 12.500 s + let second = 400 // 16.667 s + let firstLines = { capture.matching("threshold 12.500s") } + let secondLines = { capture.matching("threshold 16.667s") } + + renderer.flush(removingDisplayedImage: false) + renderer.setSkipThreshold(Self.time(first)) + for i in PresentationFloorTests.decodeOrder(gop: 8, frames: 16, startingAt: first - 4) { + renderer.enqueue(pixelBuffer: pixels, pts: Self.time(i)) + } + #expect(firstLines().count == 1) + + // A seek sets the same threshold again after the landing: still one seek, still one line, + // even though more frames below the target arrive. + // (The first frame at or past the threshold clears the renderer's own refusal; the frames + // below it that follow are the floor's, and are counted but not logged again.) + renderer.setSkipThreshold(Self.time(first)) + renderer.enqueue(pixelBuffer: pixels, pts: Self.time(first)) + renderer.enqueue(pixelBuffer: pixels, pts: Self.time(first - 2)) + renderer.enqueue(pixelBuffer: pixels, pts: Self.time(first - 1)) + #expect(firstLines().count == 1, "the same target again is not a new seek") + + // A new target without a flush in between is a new seek: its first skipped frame is logged. + renderer.setSkipThreshold(Self.time(second)) + #expect(secondLines().isEmpty) + renderer.enqueue(pixelBuffer: pixels, pts: Self.time(second)) + renderer.enqueue(pixelBuffer: pixels, pts: Self.time(second - 2)) + renderer.enqueue(pixelBuffer: pixels, pts: Self.time(second - 1)) + #expect(secondLines().count == 1) + #expect(firstLines().count == 1) + #expect(renderer.arrivalSkippedFramesDropped >= 7, "the counter stays cumulative across seeks") } @Test("Without a frame rate the renderer keeps its reorder buffer") diff --git a/Tests/AetherEngineTests/SampleBufferRendererLookaheadTests.swift b/Tests/AetherEngineTests/SampleBufferRendererLookaheadTests.swift index 98e5bc52d..3d433e69a 100644 --- a/Tests/AetherEngineTests/SampleBufferRendererLookaheadTests.swift +++ b/Tests/AetherEngineTests/SampleBufferRendererLookaheadTests.swift @@ -182,6 +182,19 @@ struct SampleBufferRendererLookaheadTests { #expect(abs(r.lead - 4 * frame) < 1e-9) } + @Test("A frame 100 s ahead of the clock is held until the clock reaches it") + func forwardJumpIsHeld() { + let frame = 1.0 / 24 + let maxLead = SampleBufferRenderer.maxReorderPictures * frame + let lead = 3 * frame + // 100 s on from the highest PTS seen: the lead is kept and the gate refuses the frame. + let d = SampleBufferRenderer.displayOrderPTS(packetPTS: 200, maxSeenPTS: 100, reorderLead: lead, maxLead: maxLead) + #expect(d.maxSeen == 200) + #expect(d.lead == lead) + #expect(!mayEnqueue(rate: 24, pts: d.pts, clock: 100)) + #expect(mayEnqueue(rate: 24, pts: d.pts, clock: 200)) + } + #if DualLayerDolbyVision @Test("The pool threshold follows the look-ahead cap") func poolFollowsCap() { diff --git a/Tests/AetherEngineTests/SoftwareAtmosRouteTests.swift b/Tests/AetherEngineTests/SoftwareAtmosRouteTests.swift index a1cdfa6ab..6119f9e76 100644 --- a/Tests/AetherEngineTests/SoftwareAtmosRouteTests.swift +++ b/Tests/AetherEngineTests/SoftwareAtmosRouteTests.swift @@ -36,6 +36,31 @@ struct SoftwareAtmosRouteTests { rendering: .off) == .pcm) } + @Test("an E-AC-3 track on the dual-layer route with object audio off gets a route line saying it is decoded to PCM") + func eac3OnDualLayerRouteLogsPCMDecode() throws { + let note = try #require(SoftwarePlaybackHost.eac3PCMRouteNote( + codecID: AV_CODEC_ID_EAC3, rendering: .off, isLive: false, dualLayerRoute: true)) + #expect(note.contains("E-AC-3")) + #expect(note.contains("PCM")) + #expect(note.contains("objectAudioRendering off")) + // A live source is kept on PCM whatever the rendering asks, and says so. + let live = try #require(SoftwarePlaybackHost.eac3PCMRouteNote( + codecID: AV_CODEC_ID_EAC3, rendering: .apac(.l714), isLive: true, dualLayerRoute: true)) + #expect(live.contains("live source")) + } + + @Test("no E-AC-3 route line off the dual-layer route, for other codecs, or when the object path decides") + func eac3RouteLineIsNarrow() { + #expect(SoftwarePlaybackHost.eac3PCMRouteNote( + codecID: AV_CODEC_ID_EAC3, rendering: .off, isLive: false, dualLayerRoute: false) == nil) + #expect(SoftwarePlaybackHost.eac3PCMRouteNote( + codecID: AV_CODEC_ID_TRUEHD, rendering: .off, isLive: false, dualLayerRoute: true) == nil) + #expect(SoftwarePlaybackHost.eac3PCMRouteNote( + codecID: AV_CODEC_ID_AC3, rendering: .off, isLive: false, dualLayerRoute: true) == nil) + #expect(SoftwarePlaybackHost.eac3PCMRouteNote( + codecID: AV_CODEC_ID_EAC3, rendering: .apac(.l714), isLive: false, dualLayerRoute: true) == nil) + } + @Test("live sessions keep the PCM decode") func liveIsPCM() { #expect(route(codec: AV_CODEC_ID_EAC3, live: true) == .pcm) diff --git a/Tests/AetherEngineTests/Support/HeavyDualLayerSuites.swift b/Tests/AetherEngineTests/Support/HeavyDualLayerSuites.swift new file mode 100644 index 000000000..8849cb8f3 --- /dev/null +++ b/Tests/AetherEngineTests/Support/HeavyDualLayerSuites.swift @@ -0,0 +1,19 @@ +import Testing + +/// The Dolby Vision suites whose cases are long GPU and CPU loops: the dual-layer decoder, seek, +/// classification and GPU-slot suites, the real-clip suite and the two fuzz suites. +/// Run beside each other and beside the rest of the run they starve one another. They used to carry +/// the one-lane trait, so every case queued for the single lane behind the long real-clip decodes, +/// and the time a case waits for the lane counts against its `.timeLimit`: a full run in the default +/// parallel mode reported time-limit issues that were no real failure (every case passes one at a time). +/// `.serialized` here is recursive: the suites nested in it run one after another, so the lane +/// is no longer needed to keep them to one core, and a case that is waiting for its turn has not +/// started, so its `.timeLimit` has not either. +@Suite(.serialized) +enum HeavyDualLayerSuites {} + +/// Other suites reach into these three for fixtures and helpers; the aliases keep those call sites +/// as they were. +typealias DoviFelFuzzTests = HeavyDualLayerSuites.DoviFelFuzzTests +typealias DoviCrcValidFuzzTests = HeavyDualLayerSuites.DoviCrcValidFuzzTests +typealias DoviFelRealFileTests = HeavyDualLayerSuites.DoviFelRealFileTests diff --git a/docs/architecture.md b/docs/architecture.md index f4e1ae7fb..94e188bbd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -612,6 +612,7 @@ Sources/AetherEngine/ │ ├── AetherEngine+FelProbe.swift `DualLayerDolbyVision` trait: FEL decode probe backing `aetherctl feltest`, and the `--cycle-el` residual switch │ ├── AetherEngine+DolbyVisionTest.swift DEBUG-only, `DualLayerDolbyVision` trait: `@_spi(Diagnostics)` hook (not host API) that arms the pin test from a debug menu or `aetherctl play --dv-pin` │ ├── Metal/ `DualLayerDolbyVision` trait only +│ │ ├── DoviFelChromaFormat.swift Chroma sampling of the composed frame: 4:2:0 (the composition), 4:2:2 and 4:4:4 (experimental, `AETHER_FEL_CHROMA`), each format's CoreVideo layout │ │ ├── DoviFelComposer.swift BL + EL + RPU composition: runtime-compiled pipelines, pooled P010 output, `composeAsync` bounded at `maxFramesInFlight` with completions on its own serial queue; RPU → `DoviMetalParams` parsing │ │ ├── DoviShaderSource.swift Luma / chroma compose kernels (reshaping, inverse NLQ, bilinear EL upsampling, ordered dither) and the ABI layout probe, compiled at runtime │ │ └── DoviMetalParams.swift Swift mirror of the kernel parameter structs and their byte-exact serializer diff --git a/docs/dolby-vision-fel.md b/docs/dolby-vision-fel.md index d757e22bd..56fd88d90 100644 --- a/docs/dolby-vision-fel.md +++ b/docs/dolby-vision-fel.md @@ -106,13 +106,16 @@ flowchart LR | Split | `DoviRpuConverter.separateDualLayers` | demux | RPU parsed into composer parameters and converted to an 8.1 NAL; EL NAL units copied into a new `AVPacket`; the BL packet goes to VideoToolbox untouched (it skips the `unspec` NAL units itself) | | Align | `DualLayerPictureAlignment.enhancementPTS` | demux, under the decoder's lock | each layer's display slot from its POC (`HEVCPictureOrderCount`); the EL packet takes the PTS of the BL picture in the same slot, so pairing by time pairs by picture order when the layers' GOPs differ. A layer whose sequence cannot be trusted (leading pictures of the opener, or more than 16 slots off its unit, or a seek before its next IRAP) keeps its access unit's PTS | | Decode | two `HardwareVideoDecoder`s | VideoToolbox output threads | asynchronous decode with temporal processing, P010 IOSurface buffers in decode order (the renderer's 4-frame reorder buffer restores display order, as for any VideoToolbox HEVC stream) | -| Pair | `DualLayerVideoDecoder.handleBaseLayerFrame` / `handleEnhancementLayerFrame` | VideoToolbox output threads, under the decoder's recursive lock | match the other layer by 90 kHz canonical PTS within ±300 ticks; a base-layer frame takes its ticket at arrival and is bypassed under it once K = 3 newer base-layer frames have arrived; waiting enhancement frames capped at 10; side data in a 32-entry ring; `hdr10Data` and the display-metadata NAL are resolved once here and travel with the ticket | +| Pair | `DualLayerVideoDecoder.handleBaseLayerFrame` / `handleEnhancementLayerFrame` | VideoToolbox output threads, under the decoder's recursive lock | match the other layer by 90 kHz canonical PTS within ±300 ticks; a base-layer frame takes its ticket at arrival and is bypassed under it by its display deadline when the decoder has a render clock (§5.5), or once K = 3 newer base-layer frames have arrived when it has none; waiting enhancement frames capped at 10; side data in a 32-entry ring; `hdr10Data` and the display-metadata NAL are resolved once here and travel with the ticket | | Compose | `DoviFelComposer.composeAsync` | caller, then Metal | wait for one of 3 in-flight slots, rent a P010 buffer (§5.6), copy colour attachments from the base layer, bind 6 texture-cache textures, serialize the parameter block, encode luma and chroma kernels in one concurrent encoder, commit | | Deliver | Metal completion handler → composer's `deliveryQueue` (composed frames) or the pairing call directly (a bypass) → `OrderedDelivery.fulfil` → `emit` | Metal's completion queue hands off immediately to the composer's own `.userInteractive` queue (§4.3's firewall, not an ordering mechanism), or the pairing thread for a bypass, then `OrderedDelivery`'s own `.userInteractive` queue — never the decoder lock | release the GPU slot inline, nothing else, on Metal's queue; everything else (error logging, GPU-time accounting, `completion`) hops off it first. Either path hands the frame to `OrderedDelivery` (§5.7): one serial queue orders composed and bypassed frames alike regardless of which queue called `fulfil`, drops anything tagged with an epoch a flush/close has since retired, and calls `onFrame` (renderer enqueue) with no decoder lock held | A frame bypasses composition, and goes out as its base layer with its own DV metadata, when the -stream is not FEL, when MEL was detected, when no RPU parameters exist for its PTS, or when -composition fails. +stream is not FEL, when MEL was detected, when no RPU parameters exist for its PTS, when its +enhancement layer does not arrive in time (§5.5), when the access unit carried none, at end of stream, +or when composition fails. A bypassed frame carries the Profile 8.1 RPU that keeps the source's +mapping, so the display reshapes the picture the composer did not; a composed frame carries the form +with the mapping removed. **Kernels** (`DoviShaderSource.swift`). Luma: base code → piece selection → polynomial (evaluated in normalized float) or MMR when the luma curve uses MMR → plus the inverse-NLQ residual of the @@ -383,9 +386,36 @@ the decoder's lock, and each call is O(K): decode; `DoviFelRealFileTests.reconstructsAndOrders` replays that rule on the real files). - If its enhancement layer is already waiting (within ±300 ticks), the pair is composed. Otherwise it waits. -- **A waiting base layer is bypassed, under its own ticket, once K newer base-layer frames have - arrived.** K (`maxLayerSkewFrames`) is the maximum layer skew, 3; the summary line reports the - observed skew (§5.11). A lost enhancement frame costs K frames of delay and never reorders output. +- **A waiting base layer is bypassed, under its own ticket, by its display deadline; with no render + clock, once K newer base-layer frames have arrived.** K (`maxLayerSkewFrames`) is the maximum layer + skew, 3, and is the whole rule only when the host installed no clock (`setRenderClock`): a lost + enhancement frame then costs K frames of delay and never reorders output. The summary line reports + the observed skew (§5.11). With a clock (the software host installs one) the rule is the deadline: + - *Deadline.* A waiting frame is released as its base layer once its presentation time less a margin + has been reached on the running clock. The margin is `maxFramesInFlight` (3) frame intervals, at + least 100 ms. Its presentation time is the earliest of its own and those of the frames behind it, + since delivery is ordered by ticket and none of them can show first. + - *Grace.* A frame is given one frame interval, clamped to 15 to 50 ms, from its arrival before the + deadline may release it, because the two layers decode on separate sessions and the partner of a + frame that arrives already inside its margin is normally a moment behind, not lost. A frame already + at or past its presentation time gets none. + - *Held clock.* With no running clock there is no time to count down to. While a preroll waits on the + renderer a frame waits the margin of wall time at most; for a pause, or before the clock is armed, + it waits 2 s at most (this only bounds a layer that is lost for good). + - *Backstop.* With a clock, `maxDeadlineHoldFrames` = 7 newer arrivals release a frame in every mode. + It sits two below the point (`maxFramesInFlight + maxLayerSkew + 2` = 8 pending frames) at which + `OrderedDelivery`'s stall guard skips a ticket, so the frames queued behind a waiting one can never + reach the guard, and it bounds the queue. + - *Sweep.* A timer re-checks what waits (every 2 to 250 ms, only while something waits) and every + arrival re-arms it, since an arrival can lower a waiting frame's deadline. + - *No enhancement layer in the access unit.* An access unit that carries no enhancement-layer NAL has + no picture to wait for, so its base frame is released at once (`bypassNoELInAU`). That is judged only + while the layers' pictures sit in the same units; once the alignment has restamped any (a reordered + stream), a unit without one says nothing about its own frame. A reordered stream is recognised only + after its first restamp, so a frame in that start-up stretch (the opening of a play and of each seek) + can be released early and shown as its base layer. + - The count `K` also sets how far back the picture alignment will restamp (`maxBackwardRestamp`); with + a clock a frame waits longer than `K` arrivals, so that bound is conservative there. - An enhancement frame whose base layer has already gone out (its key matches one of the last 64 settled base-layer keys) is dropped on arrival. At most 10 enhancement frames wait. - A frame's side data is looked up once, when its base layer arrives, and travels with the ticket; @@ -408,6 +438,40 @@ the decoder's lock, and each call is O(K): threshold a burst draws on a reserve of 3 more. Only with the reserve spent does the composer wait, for at most about 36 ms (a 300 ms wait stalled the VideoToolbox callback and starved audio into rebuffers on device), and then the frame bypasses instead of growing the pool. +- **Chroma sampling (experimental switch).** The composition is defined on the 4:2:0 sites, where the base + layer's chroma sits, and 4:2:0 is the output unless `AETHER_FEL_CHROMA` says otherwise. `422` and `444` + step that result up by co-sited means: a copy on a site, and the mean of the sites either side between + them (the four around a sample in 4:4:4). Each step-up site is composed again from its neighbours, so + 4:2:2 does about four times the chroma work of 4:2:0 and 4:4:4 about sixteen times; a pass that composes + the 4:2:0 sites once into a scratch texture would remove that. Both take the pool path, since in-place + composition is 4:2:0 only. 4:4:4 is experimental: the spec has no 4:4:4 reconstruction. Neither format is + checked on a TV yet. +- **In place.** When the decoded base-layer buffer allows it, the result is written into that buffer + instead of a pool buffer, so the output pool above is not created unless a frame is refused for in-place + (for example a luma MMR curve). It is used when the buffer is + IOSurface-backed, in the 10-bit biplanar video-range format (`x420`), has even dimensions with the + luma and half-size chroma planes the output has, and the frame's luma curve is not MMR; anything else + takes the pool path unchanged. Setting `AETHER_FEL_INPLACE=0` turns it off for an A/B run. + - *Why no scratch buffer.* The chroma kernel reads the base-layer luma around each chroma sample + (other threads' pixels) but only its own chroma sample. The luma kernel reads only its own luma sample. + So chroma runs first, while all the luma is still the decoded base layer, and each thread overwrites + just the chroma sample it has read; luma runs second, each thread overwriting just its own sample, and + nothing reads luma any more. A serial encoder and a texture barrier between the passes make the second + wait for the first. A luma MMR curve would read chroma in the luma pass, after chroma was overwritten, + so such a frame uses the pool (no stream seen so far has one). + - *Exclusive buffer.* The decoder hands the buffer to the composition when it pairs the frame, keeps no + other reference that reads pixels, and delivers it to the renderer only from the completion. The + composer refuses a second composition of a buffer while one is outstanding (that request is dropped and + counted as lost, it does not take the pool path, which would read pixels the first one is still writing), and the residual + measurement (`AETHER_FEL_MEASURE`), which composes the base layer again from another queue, runs on + the pool path. + - *Failure.* A frame that cannot be set up is untouched and goes out as its base layer, as before. A + command buffer that fails after it started leaves a mix of base layer and composed pixels in the + buffer; that frame is dropped (`OrderedDelivery.skip` keeps its place in line), counted as a compose + failure and as `inPlaceDropped` in the summary, and nothing retries it. + - *Attachments.* The buffer already holds the base layer's attachments, which is what the pool path + copies over. The attachments that do not propagate are removed, so the result carries the same set a + pool buffer would; the Dolby Vision attachment is set on delivery as for any frame. - **Texture cache** is flushed from the 1 Hz tick, which releases textures for buffers no longer in use. - **Colour attachments** are copied from the base layer when encoding, on the calling thread, not on Metal's completion queue. @@ -532,9 +596,9 @@ pictures and the display layer's queue are extra): | Holder | Bound | |---|---| -| Base-layer frames waiting for their EL | K = 3 → 75 MB | +| Base-layer frames waiting for their EL | up to `maxDeadlineHoldFrames` = 7 with a render clock (K = 3 without one) → about 175 MB at 4K with the clock, 75 MB at K = 3. The 7 are reached only while a stretch of enhancement layers is missing; in normal playback a frame waits a few milliseconds | | Enhancement-layer frames waiting | 10 → 62 MB | -| Composed output buffers (in flight, pending, idle in pool) | allocation threshold 15 + reserve 3 → 448 MB, the reserve freed after a second unused | +| Composed output buffers (in flight, pending, idle in pool) | allocation threshold 15 + reserve 3 → 448 MB, the reserve freed after a second unused. **None in place unless a frame is refused for in-place (e.g. luma MMR):** the composed frame is the decoded base-layer buffer, which VideoToolbox's own pool then keeps alive until the renderer lets go of it (the memory moves to the decoder's side rather than vanishing). Approximate: another aetherctl ran on the same machine during these runs, so the numbers are noisy. Mac, 4K, 200 s with 7 seeks, two runs each: our process plus the decoder service, median 841 and 903 MB before, 708 and 744 MB in place (about 147 MB less); peak 936 and 965 MB before, 761 and 817 MB in place (about 161 MB less). Our process alone falls by about 300 MB, the decoder service gains about 170 MB | CPU per composed frame: @@ -554,9 +618,13 @@ GPU: 12.9–14.3 ms per frame measured on the A12 (§4.2), against a 41.7 ms bud ### 5.11 Observability One `[DualLayerVideoDecoder] summary` line per second while composing: frames composed, compose -failures, bypassed without EL, bypassed without RPU, stale enhancement frames dropped, maximum -observed layer skew in frames, peak GPU slots in use, output pool buffers allocated, delivery pending -peak, mean GPU ms. `[DoviFelComposer] gpu … ms/frame` is the same kind of line, once per 120 composed +failures, base frames bypassed without an enhancement layer, split by the rule that released them +(`bypassNoELInAU`: no enhancement-layer NAL in the access unit; `bypassDeadline`: the display deadline; +`bypassHeldWall`: the wall-time bound on a held clock; `bypassBackstop`: the count of 7, or K with no +clock; `bypassNoELDecoder`: no enhancement decoder; `bypassEndOfStream`: released at end of stream), +bypassed without RPU, stale enhancement frames dropped, maximum +observed layer skew in frames, peak GPU slots in use, output pool buffers allocated (0 in place), frames composed in place so far, +frames dropped after a failed in-place composition, delivery pending peak, mean GPU ms. `[DoviFelComposer] gpu … ms/frame` is the same kind of line, once per 120 composed frames. Both are `.verbose`: they go to OSLog's debug level and never reach the host handler, so they cost nothing unless the category's debug level is on: @@ -564,6 +632,13 @@ cost nothing unless the category's debug level is on: log stream --level debug --predicate 'subsystem == "de.superuser404.AetherEngine" && category == "sw.playback"' ``` +A second line, `[DualLayerVideoDecoder] totals: composed=…, bypassDeadline=…, bypassHeldWall=…, bypassBackstop=…`, +goes out at the default level (so a host's log carries it without a debug build): the session-wide +count of composed frames and of the base frames released by the deadline, the held-wall bound and the +backstop. It is sent at most every 10 s and only when one of the counts moved since the last one, so a +device run is judged from it. The software host's `[SWDiag]` line carries `skip=`, the number of frames +the renderer skipped on arrival because the clock had already passed them, next to `lost=` and `layerDrop=`. + The exception is a summary whose second counted a compose failure, which goes out at the default level so the failure is visible without that. `os_signpost` intervals around compose and delivery make the pipeline readable in Instruments. Nothing else logs per frame; anything genuinely per-frame for @@ -582,6 +657,7 @@ debugging, such as the pairing, queueing and bypass traces, uses `EngineLog` lev | I7 | Held frames are bounded: waiting EL, waiting BL, GPU in flight | `orphanedEnhancementLayersAreBounded`, `lostEnhancementLayerDelaysBaseLayerByAtMostKFrames`; `DoviComposeAsyncTests.completionsAreExactlyOnceAndBounded`. **Not bounded on its own: the delivery queue.** A slow `onFrame` does not throttle pairing (a composed frame's GPU slot is released before delivery), so nothing bounds the backlog in `OrderedDelivery` itself. The demux thread's `mayEnqueueVideoFrame` gate limits how fast new packets arrive, which limits the backlog only indirectly; `flush()`'s `barrier()` waits for the queue to empty but does not cap it. | | I8 | No lock is held across a blocking wait or a host callback | `DualLayerVideoDecoderTests.slowOnFrameDoesNotBlockDecoderOperations` (a blocked `onFrame` never stalls concurrent decoder work); `DualLayerGpuSlotWaitTests` for the GPU slot wait | | I9 | Stream classification changes only at open, flush or close | `DualLayerStreamClassificationTests` | +| I10 | An in-place composition is bit-identical to the pool composition; a buffer is never composed twice or shown half composed | `DoviFelInPlaceComposeTests` (`inPlaceMatchesPool`, `sameBufferIsNotComposedTwice`, `failedInPlaceIsCorrupt`, `decoderDropsCorruptFrame`) | ---