From 85ecaf1f2ceb98c18ae0b4b1cbba426431fded51 Mon Sep 17 00:00:00 2001
From: Philipp Bischoff
Date: Tue, 29 Sep 2026 22:57:01 +0200
Subject: [PATCH 1/4] fix(ci): keep the sources within Swift 6.2's type checker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CI has been red since 069a818, and #39/#40 added two more failures of
the same kind. The workflow asks for Xcode_26.app, which the macos-15
image no longer has; the fallback selects Xcode 26.3 — Swift 6.2 —
while local development runs Xcode 27 (Swift 6.4). Code that 6.4
accepts at once, 6.2 rejects:
- ChannelDrain.value: a bare `result` inside the initializer of the
local `result` binds to that local on 6.2 ("initializer for
conditional binding must have Optional type"). Now `self.result`.
- Matrix3.multiply/inverse: nested `reduce(0)` closures over untyped
literals exceed 6.2's solver ("unable to type-check this expression
in reasonable time"). Plain loops with explicit types.
- DolbyVisionTests.extraction: `#expect(pieces == [.polynomial(...)])`
took 6.4 2.4 s to type-check; the expected values are now typed
locals. HEVCBaseLayerFilterTests' hvcC array builder (0.1 s) is
a loop.
Found by compiling with -warn-long-expression-type-checking=25 on 6.4:
nothing is above 40 ms any more, the level the existing tests already
passed CI at. Behaviour is unchanged. The suite passes apart from the intermittent
SRT cue test, a real duplicate-cue bug fixed in the next commit.
---
.../Decode/DolbyVisionMapping.swift | 42 ++++++++++++++-----
.../DolbyVisionTests.swift | 22 ++++++----
.../HEVCBaseLayerFilterTests.swift | 9 +++-
.../Support/ChannelDrain.swift | 7 +++-
4 files changed, 58 insertions(+), 22 deletions(-)
diff --git a/Sources/LumeEngineCore/Decode/DolbyVisionMapping.swift b/Sources/LumeEngineCore/Decode/DolbyVisionMapping.swift
index e532103..a386ba7 100644
--- a/Sources/LumeEngineCore/Decode/DolbyVisionMapping.swift
+++ b/Sources/LumeEngineCore/Decode/DolbyVisionMapping.swift
@@ -169,22 +169,41 @@ extension DolbyVisionMapping {
/// Row-major 3×3 helpers. Tiny and allocation-happy by design: they run once
/// per frame on nine numbers.
+///
+/// Written as plain loops with every type spelled out. The closure-and-literal
+/// form (`reduce(0) { $0 + a[…] * b[…] }` inside a `map`) type-checks
+/// instantly on Swift 6.4 but exceeds Swift 6.2's solver limit ("unable to
+/// type-check this expression in reasonable time") — which is what CI's
+/// Xcode 26.3 runs.
enum Matrix3 {
static func multiply(_ a: [Double], _ b: [Double]) -> [Double] {
- (0..<9).map { index in
- let row = index / 3, column = index % 3
- return (0..<3).reduce(0) { $0 + a[row * 3 + $1] * b[$1 * 3 + column] }
+ var product = [Double](repeating: 0, count: 9)
+ for row in 0..<3 {
+ for column in 0..<3 {
+ var sum: Double = 0
+ for k in 0..<3 {
+ sum += a[row * 3 + k] * b[k * 3 + column]
+ }
+ product[row * 3 + column] = sum
+ }
}
+ return product
}
static func inverse(_ m: [Double]) -> [Double] {
- let cofactors = [
- m[4] * m[8] - m[5] * m[7], m[2] * m[7] - m[1] * m[8], m[1] * m[5] - m[2] * m[4],
- m[5] * m[6] - m[3] * m[8], m[0] * m[8] - m[2] * m[6], m[2] * m[3] - m[0] * m[5],
- m[3] * m[7] - m[4] * m[6], m[1] * m[6] - m[0] * m[7], m[0] * m[4] - m[1] * m[3],
+ // Cofactor (i, j) of the transpose, i.e. the adjugate, row-major.
+ func minor(_ a: Int, _ b: Int, _ c: Int, _ d: Int) -> Double {
+ let lhs: Double = m[a] * m[b]
+ let rhs: Double = m[c] * m[d]
+ return lhs - rhs
+ }
+ let adjugate: [Double] = [
+ minor(4, 8, 5, 7), minor(2, 7, 1, 8), minor(1, 5, 2, 4),
+ minor(5, 6, 3, 8), minor(0, 8, 2, 6), minor(2, 3, 0, 5),
+ minor(3, 7, 4, 6), minor(1, 6, 0, 7), minor(0, 4, 1, 3),
]
- let determinant = m[0] * cofactors[0] + m[1] * cofactors[3] + m[2] * cofactors[6]
- return cofactors.map { $0 / determinant }
+ let determinant: Double = m[0] * adjugate[0] + m[1] * adjugate[3] + m[2] * adjugate[6]
+ return adjugate.map { (value: Double) -> Double in value / determinant }
}
/// Linear LMS (Hunt-Pointer-Estevez, no crosstalk) to linear BT.2020 RGB.
@@ -194,8 +213,9 @@ enum Matrix3 {
/// `M = X(0.04) · HPE`, where `X(c)` has `1 − 2c` on the diagonal and `c`
/// elsewhere. So `HPE⁻¹ = M⁻¹ · X(0.04)`.
static let hpeLMSToBT2020: [Double] = {
- let ictcp = [1688.0, 2146, 262, 683, 2951, 462, 99, 309, 3688].map { $0 / 4096 }
- let crosstalk = [0.92, 0.04, 0.04, 0.04, 0.92, 0.04, 0.04, 0.04, 0.92]
+ let ictcpCodes: [Double] = [1688, 2146, 262, 683, 2951, 462, 99, 309, 3688]
+ let ictcp = ictcpCodes.map { (code: Double) -> Double in code / 4096 }
+ let crosstalk: [Double] = [0.92, 0.04, 0.04, 0.04, 0.92, 0.04, 0.04, 0.04, 0.92]
return multiply(inverse(ictcp), crosstalk)
}()
}
diff --git a/Tests/LumeEngineCoreTests/DolbyVisionTests.swift b/Tests/LumeEngineCoreTests/DolbyVisionTests.swift
index ee96d30..bf08798 100644
--- a/Tests/LumeEngineCoreTests/DolbyVisionTests.swift
+++ b/Tests/LumeEngineCoreTests/DolbyVisionTests.swift
@@ -84,15 +84,23 @@ struct DolbyVisionTests {
#expect(parsed.baseLayerBitDepth == 10)
#expect(parsed.curves.count == 3)
- #expect(parsed.curves[0].pivots == [0, 400.0 / 1023, 1])
- #expect(parsed.curves[0].pieces == [
+ // Expected values are typed up front rather than written inline in
+ // `#expect`: comparing against an untyped literal of enum cases took
+ // Swift 6.4 over two seconds to type-check, beyond what Swift 6.2
+ // (CI's Xcode 26.3) accepts at all.
+ let expectedPivots: [Double] = [0, 400.0 / 1023, 1]
+ let expectedI: [DolbyVisionMapping.Piece] = [
.polynomial([-612_867.0 / 8_388_608, 9_803_467.0 / 8_388_608, 0]),
.polynomial([1, 0, -0.25]),
- ])
- #expect(parsed.curves[1].pieces == [
- .mmr(constant: -0.125, coefficients: [[1, 2, 3, 4, 5, 6, 7], [-1, -2, -3, -4, -5, -6, -7]]),
- ])
- #expect(parsed.curves[2].pieces == [.polynomial([0, 1, 0])])
+ ]
+ let mmrRows: [[Double]] = [[1, 2, 3, 4, 5, 6, 7], [-1, -2, -3, -4, -5, -6, -7]]
+ let expectedP: [DolbyVisionMapping.Piece] = [.mmr(constant: -0.125, coefficients: mmrRows)]
+ let expectedT: [DolbyVisionMapping.Piece] = [.polynomial([0, 1, 0])]
+
+ #expect(parsed.curves[0].pivots == expectedPivots)
+ #expect(parsed.curves[0].pieces == expectedI)
+ #expect(parsed.curves[1].pieces == expectedP)
+ #expect(parsed.curves[2].pieces == expectedT)
#expect(parsed.yccToRGB[0] == 1)
#expect(parsed.yccToRGB[1] == 799.0 / 8192)
diff --git a/Tests/LumeEngineCoreTests/HEVCBaseLayerFilterTests.swift b/Tests/LumeEngineCoreTests/HEVCBaseLayerFilterTests.swift
index a962666..5ca5465 100644
--- a/Tests/LumeEngineCoreTests/HEVCBaseLayerFilterTests.swift
+++ b/Tests/LumeEngineCoreTests/HEVCBaseLayerFilterTests.swift
@@ -87,8 +87,13 @@ struct HEVCBaseLayerFilterTests {
@Test("hvcC: enhancement parameter sets and the arrays they empty are removed")
func hvcC() {
func array(_ type: UInt8, _ units: [[UInt8]]) -> [UInt8] {
- [0x80 | type, UInt8(units.count >> 8), UInt8(units.count & 0xFF)]
- + units.flatMap { [UInt8($0.count >> 8), UInt8($0.count & 0xFF)] + $0 }
+ var bytes: [UInt8] = [0x80 | type, UInt8(units.count >> 8), UInt8(units.count & 0xFF)]
+ for unit in units {
+ bytes.append(UInt8(unit.count >> 8))
+ bytes.append(UInt8(unit.count & 0xFF))
+ bytes.append(contentsOf: unit)
+ }
+ return bytes
}
let header = [UInt8](repeating: 0xEE, count: 22)
let vps = Self.nal(type: 32, layer: 0), sps = Self.nal(type: 33, layer: 0)
diff --git a/Tests/LumeEngineCoreTests/Support/ChannelDrain.swift b/Tests/LumeEngineCoreTests/Support/ChannelDrain.swift
index 39a92be..8108aae 100644
--- a/Tests/LumeEngineCoreTests/Support/ChannelDrain.swift
+++ b/Tests/LumeEngineCoreTests/Support/ChannelDrain.swift
@@ -85,9 +85,12 @@ final class ChannelDrain: @unchecked Sendable {
get async {
let result: Result = await withCheckedContinuation { continuation in
lock.lock()
- if let result {
+ // `self.` is load-bearing: inside the initializer of the local
+ // `result`, Swift 6.2 (CI's Xcode 26.3) binds a bare `result`
+ // to that local, not to the stored property, and rejects it.
+ if let stored = self.result {
lock.unlock()
- continuation.resume(returning: result)
+ continuation.resume(returning: stored)
} else {
waiter = continuation
lock.unlock()
From 96c8c6ba6f39d109804bfe328d643f37492f1d14 Mon Sep 17 00:00:00 2001
From: Philipp Bischoff
Date: Tue, 29 Sep 2026 23:10:36 +0200
Subject: [PATCH 2/4] fix(subtitle): store a re-read cue once, not again
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"embedded SRT track produces timed cues" failed intermittently — on
clean main as well, one run in three to nine. Printing the store on a
failing run showed every cue twice (ids 0/2 and 1/3), and the bug is
not the test's.
SubtitleStore is a timeline that deliberately survives seeks, but the
decoder inserted every cue it decoded. So whenever the demuxer re-read
subtitle packets it had already delivered, the cues were stored again:
- on every backward seek — users saw the lines of that range doubled;
- in the backfill seek of a track selected after the demuxer had read
past it (selectSubtitleTrack), which is the test's path: when live
delivery won the race against that seek, both copies landed.
insert() now drops a cue identical to one it holds (same start, end,
and trimmed text); cues sharing a start but differing otherwise both
stay. The same-start run sits right before the insertion point, so the
check costs nothing extra.
New tests: a store unit test, and "seeking back does not duplicate
cues", which reproduces the doubling deterministically — both fail
without the fix. The full suite passed six runs out of six afterwards.
---
.../LumeEngineCore/Subtitle/SubtitleCue.swift | 20 +++++++--
Tests/LumeEngineCoreTests/SubtitleTests.swift | 44 +++++++++++++++++++
2 files changed, 60 insertions(+), 4 deletions(-)
diff --git a/Sources/LumeEngineCore/Subtitle/SubtitleCue.swift b/Sources/LumeEngineCore/Subtitle/SubtitleCue.swift
index 7b171cf..a262a93 100644
--- a/Sources/LumeEngineCore/Subtitle/SubtitleCue.swift
+++ b/Sources/LumeEngineCore/Subtitle/SubtitleCue.swift
@@ -20,6 +20,13 @@ public struct SubtitleCue: Sendable, Equatable, Identifiable {
/// Time-indexed cue container, safe for concurrent insert (decoder thread) and
/// query (UI tick). Cues are kept sorted by start time.
+///
+/// Cues outlive seeks on purpose — the store is a timeline, not a queue — so
+/// the same packet reaches it again whenever the demuxer re-reads a range it
+/// already delivered: a backward seek, or the seek that backfills a subtitle
+/// track selected after the demuxer read past its packets. An identical cue
+/// (same start, end, and text) is therefore dropped, or every re-read would
+/// show its lines doubled.
public final class SubtitleStore: @unchecked Sendable {
private let lock = NSLock()
private var cues: [SubtitleCue] = []
@@ -32,12 +39,17 @@ public final class SubtitleStore: @unchecked Sendable {
guard !trimmed.isEmpty, end > start else { return }
lock.lock()
defer { lock.unlock() }
- let cue = SubtitleCue(id: nextID, start: start, end: end, text: trimmed)
- nextID += 1
// Insertion point by start time (cues arrive nearly sorted).
var index = cues.count
- while index > 0 && cues[index - 1].start > cue.start { index -= 1 }
- cues.insert(cue, at: index)
+ while index > 0 && cues[index - 1].start > start { index -= 1 }
+ // Cues with the same start sit directly before the insertion point.
+ var existing = index - 1
+ while existing >= 0 && cues[existing].start == start {
+ if cues[existing].end == end && cues[existing].text == trimmed { return }
+ existing -= 1
+ }
+ cues.insert(SubtitleCue(id: nextID, start: start, end: end, text: trimmed), at: index)
+ nextID += 1
}
/// All cues covering `time` (engine µs).
diff --git a/Tests/LumeEngineCoreTests/SubtitleTests.swift b/Tests/LumeEngineCoreTests/SubtitleTests.swift
index 75507da..c48132b 100644
--- a/Tests/LumeEngineCoreTests/SubtitleTests.swift
+++ b/Tests/LumeEngineCoreTests/SubtitleTests.swift
@@ -35,6 +35,25 @@ struct SubtitleParserTests {
#expect(Set(store.activeCues(at: 4_500_000).map(\.text)) == ["overlap", "two"])
#expect(store.activeCues(at: 6_500_000).isEmpty)
}
+
+ /// Re-reading a range — backward seek, or the backfill seek of a track
+ /// selected late — delivers the same packets again. Stored twice, every
+ /// line of that range showed doubled.
+ @Test("store drops a cue it already holds, and only that")
+ func storeDeduplicates() {
+ let store = SubtitleStore()
+ store.insert(start: 1_000_000, end: 3_000_000, text: "one")
+ store.insert(start: 4_000_000, end: 6_000_000, text: "two")
+ store.insert(start: 1_000_000, end: 3_000_000, text: "one")
+ store.insert(start: 1_000_000, end: 3_000_000, text: " one\n") // trims to the same cue
+ #expect(store.count == 2)
+ #expect(store.activeCues(at: 2_000_000).map(\.text) == ["one"])
+
+ // Same start, but a different cue: both stay.
+ store.insert(start: 1_000_000, end: 3_000_000, text: "other speaker")
+ store.insert(start: 1_000_000, end: 2_000_000, text: "one")
+ #expect(store.count == 4)
+ }
}
@Suite("Tracks & subtitles", .serialized)
@@ -89,6 +108,31 @@ struct TrackSubtitleTests {
await session.shutdown()
}
+ /// A backward seek re-reads subtitle packets the store already holds. Before
+ /// the store deduplicated, every re-read range showed its lines twice — and
+ /// `embeddedSubtitles` flaked whenever its backfill seek landed after live
+ /// delivery had already stored the cues.
+ @Test("seeking back does not duplicate cues", .timeLimit(.minutes(1)))
+ func seekBackKeepsCuesUnique() async throws {
+ let session = makeSession()
+ let info = try await session.open(url: try Fixtures.path("multitrack.mkv"))
+ let subtitleTrack = try #require(info.subtitleTracks.first)
+
+ await session.selectSubtitleTrack(subtitleTrack.index)
+ await session.play()
+ let sawCues = await eventually { session.subtitles.count >= 2 }
+ #expect(sawCues, "both cues should decode first, store has \(session.subtitles.count)")
+
+ await session.seek(to: 0)
+ // The 8 s fixture is re-read within milliseconds of the seek; give the
+ // subtitle lane ample time to decode it all a second time.
+ let grew = await eventually(timeout: 2) { session.subtitles.count > 2 }
+ #expect(!grew, "re-read cues were stored again: \(session.subtitles.count) cues")
+ #expect(session.subtitles.activeCues(at: info.startTime + 2_000_000).map(\.text) == ["Hello from LumeEngine"])
+
+ await session.shutdown()
+ }
+
@Test("subtitles selected before play backfill cues the demuxer already read", .timeLimit(.minutes(1)))
func subtitlesSelectedBeforePlayBackfill() async throws {
// multitrack.mkv is 8 s and the default read-ahead is 15 s, so open()
From 84f846636f03a48347159d27e2b2ac934683902f Mon Sep 17 00:00:00 2001
From: Philipp Bischoff
Date: Tue, 29 Sep 2026 23:19:13 +0200
Subject: [PATCH 3/4] fix(ci): take the test target within Swift 6.2's type
checker too
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
With the engine fixed, CI's Xcode 26.3 got as far as the tests and
rejected three more expressions that Swift 6.4 checks in a few
milliseconds — so -warn-long-expression-type-checking on 6.4 is no
guide to what 6.2 accepts:
- DolbyVisionTests' Reference.multiply/apply: the same nested
`map { reduce(0) { … } }` shape Matrix3 had; now loops. Its MMR sum,
the same shape, goes with them.
- HEVCBaseLayerFilterTests.hvcC: long `header + [6] + array(…) + …`
chains over untyped literals; now joined through a typed variadic.
---
.../DolbyVisionTests.swift | 32 +++++++++++++++----
.../HEVCBaseLayerFilterTests.swift | 19 +++++++----
2 files changed, 39 insertions(+), 12 deletions(-)
diff --git a/Tests/LumeEngineCoreTests/DolbyVisionTests.swift b/Tests/LumeEngineCoreTests/DolbyVisionTests.swift
index bf08798..cb21481 100644
--- a/Tests/LumeEngineCoreTests/DolbyVisionTests.swift
+++ b/Tests/LumeEngineCoreTests/DolbyVisionTests.swift
@@ -454,11 +454,15 @@ private struct Reference {
out = c[0] + c[1] * s + c[2] * s * s
case .mmr(let constant, let rows):
let (y, u, v) = (sig[0], sig[1], sig[2])
- let terms = [y, u, v, y * u, y * v, u * v, y * u * v]
- out = rows.enumerated().reduce(constant) { total, row in
- let order = Double(row.offset + 1)
- return total + zip(row.element, terms).reduce(0) { $0 + $1.0 * pow($1.1, order) }
+ let terms: [Double] = [y, u, v, y * u, y * v, u * v, y * u * v]
+ var total: Double = constant
+ for (row, coefficients) in rows.enumerated() {
+ let order = Double(row + 1)
+ for (coefficient, term) in zip(coefficients, terms) {
+ total += coefficient * pow(term, order)
+ }
}
+ out = total
}
return min(max(out, 0), 1)
}
@@ -480,12 +484,28 @@ private struct Reference {
0.01736321, -0.04725154, 1.03004253,
]
+ // Loops with explicit types, not `map { reduce(0) { … } }`: the closure
+ // form is beyond Swift 6.2's type checker (CI's Xcode 26.3).
private static func apply(_ m: [Double], _ v: [Double]) -> [Double] {
- (0..<3).map { row in (0..<3).reduce(0) { $0 + m[row * 3 + $1] * v[$1] } }
+ var result = [Double](repeating: 0, count: 3)
+ for row in 0..<3 {
+ var sum: Double = 0
+ for k in 0..<3 { sum += m[row * 3 + k] * v[k] }
+ result[row] = sum
+ }
+ return result
}
private static func multiply(_ a: [Double], _ b: [Double]) -> [Double] {
- (0..<9).map { index in (0..<3).reduce(0) { $0 + a[index / 3 * 3 + $1] * b[$1 * 3 + index % 3] } }
+ var result = [Double](repeating: 0, count: 9)
+ for row in 0..<3 {
+ for column in 0..<3 {
+ var sum: Double = 0
+ for k in 0..<3 { sum += a[row * 3 + k] * b[k * 3 + column] }
+ result[row * 3 + column] = sum
+ }
+ }
+ return result
}
private static let m1 = 2610.0 / 16384, m2 = 2523.0 / 4096 * 128
diff --git a/Tests/LumeEngineCoreTests/HEVCBaseLayerFilterTests.swift b/Tests/LumeEngineCoreTests/HEVCBaseLayerFilterTests.swift
index 5ca5465..a983e04 100644
--- a/Tests/LumeEngineCoreTests/HEVCBaseLayerFilterTests.swift
+++ b/Tests/LumeEngineCoreTests/HEVCBaseLayerFilterTests.swift
@@ -100,15 +100,22 @@ struct HEVCBaseLayerFilterTests {
let pps = Self.nal(type: 34, layer: 0), sei = Self.nal(type: 39, layer: 0)
let sps1 = Self.nal(type: 33, layer: 1), pps1 = Self.nal(type: 34, layer: 1)
- let merged = header + [6]
- + array(32, [vps]) + array(33, [sps]) + array(34, [pps]) + array(39, [sei])
- + array(33, [sps1]) + array(34, [pps1])
- let expected = header + [4] + array(32, [vps]) + array(33, [sps]) + array(34, [pps]) + array(39, [sei])
+ // Joined through a typed variadic, not `a + [6] + b + …`: a long `+`
+ // chain over untyped literals is beyond Swift 6.2's type checker.
+ func bytes(_ parts: [UInt8]...) -> [UInt8] { Array(parts.joined()) }
+
+ let merged = bytes(
+ header, [6],
+ array(32, [vps]), array(33, [sps]), array(34, [pps]), array(39, [sei]),
+ array(33, [sps1]), array(34, [pps1])
+ )
+ let expected = bytes(header, [4], array(32, [vps]), array(33, [sps]), array(34, [pps]), array(39, [sei]))
merged.withUnsafeBytes { #expect(HEVCBaseLayerFilter.baseLayerHVCC($0) == expected) }
// Mixed within one array: the array stays, with a corrected count.
- let shared = header + [1] + array(33, [sps, sps1])
- shared.withUnsafeBytes { #expect(HEVCBaseLayerFilter.baseLayerHVCC($0) == header + [1] + array(33, [sps])) }
+ let shared = bytes(header, [1], array(33, [sps, sps1]))
+ let sharedExpected = bytes(header, [1], array(33, [sps]))
+ shared.withUnsafeBytes { #expect(HEVCBaseLayerFilter.baseLayerHVCC($0) == sharedExpected) }
expected.withUnsafeBytes { #expect(HEVCBaseLayerFilter.baseLayerHVCC($0) == nil, "single-layer hvcC stays as is") }
Array(merged.dropLast()).withUnsafeBytes { #expect(HEVCBaseLayerFilter.baseLayerHVCC($0) == nil, "truncated: untouched") }
From beaf2518bfcfe6b7474d88091d7868eaee52ef27 Mon Sep 17 00:00:00 2001
From: Philipp Bischoff
Date: Tue, 29 Sep 2026 23:27:19 +0200
Subject: [PATCH 4/4] fix(test): don't take VideoToolbox's word on AV1 hardware
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On CI the AV1 test failed with 72 of ~96 frames, all from software,
although VTIsHardwareDecodeSupported(AV1) said true. The runner is a
virtualized Mac whose VideoToolbox claims AV1 and then fails it; the
engine did what its recovery policy says — rebuilt on dav1d, emitted
.downgradedToSoftware, resumed at the next keyframe, one 24-frame GOP
later. The test assumed the claim always holds.
It now tells the cases apart. Without claimed AV1 hardware — the case
that played black (Apple TV 4K, M1) — dav1d must decode from the first
frame, with no hardware attempt and nothing lost. With claimed hardware
it accepts either hardware frames, or a reported downgrade that loses
at most one GOP; never a silent failure.
---
Tests/LumeEngineCoreTests/DecoderTests.swift | 29 ++++++++++++++++----
1 file changed, 23 insertions(+), 6 deletions(-)
diff --git a/Tests/LumeEngineCoreTests/DecoderTests.swift b/Tests/LumeEngineCoreTests/DecoderTests.swift
index 3f472fa..a34ca04 100644
--- a/Tests/LumeEngineCoreTests/DecoderTests.swift
+++ b/Tests/LumeEngineCoreTests/DecoderTests.swift
@@ -143,8 +143,10 @@ struct DecoderTests {
}
var failure: EngineError?
var reachedEndOfStream = false
+ var downgraded = false
while let event = await decodeEvents.next() {
if case .failed(let error) = event { failure = error; break }
+ if case .downgradedToSoftware = event { downgraded = true }
if case .endOfStream = event { reachedEndOfStream = true; break }
}
let hardwareActive = decoder.isHardwareActive
@@ -153,17 +155,32 @@ struct DecoderTests {
#expect(failure == nil, "AV1 must decode on this host: \(failure.map(String.init(describing:)) ?? "")")
#expect(reachedEndOfStream)
let collected = await collector.value
- #expect(collected.count >= 90, "4 s @ 24 fps ≈ 96 frames, got \(collected.count)")
#expect(collected.allSatisfy { $0.width == 320 && $0.height == 180 })
let ptsValues = collected.map(\.pts)
#expect(zip(ptsValues, ptsValues.dropFirst()).allSatisfy { $0 < $1 }, "output PTS must be monotonic")
- // Hardware exactly where the chip has an AV1 decoder, software
- // (dav1d) everywhere else — never the hardware-only native decoder
- // on a chip that cannot run it.
let chipDecodesAV1 = VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1)
- #expect(hardwareActive == chipDecodesAV1)
- #expect(Set(collected.map(\.hardware)) == [chipDecodesAV1])
+ if !chipDecodesAV1 {
+ // The case that played black: no AV1 hardware (Apple TV 4K, M1,
+ // …). dav1d from the first frame — no failed hardware attempt,
+ // nothing lost.
+ #expect(!downgraded, "without AV1 hardware the hardware path must not even be tried")
+ #expect(!hardwareActive)
+ #expect(collected.allSatisfy { !$0.hardware })
+ #expect(collected.count >= 90, "4 s @ 24 fps ≈ 96 frames, got \(collected.count)")
+ } else if downgraded {
+ // VideoToolbox claimed AV1 but failed — CI's virtualized macOS
+ // does exactly that. The engine's ordinary recovery: rebuild on
+ // dav1d, report it, resume at the next keyframe (one GOP, 24
+ // frames, of the fixture).
+ #expect(!hardwareActive)
+ let afterOneLostGOP: Int = 66 // 96 frames − one 24-frame GOP − slack
+ #expect(collected.count >= afterOneLostGOP, "at most one GOP lost to the recovery, got \(collected.count)")
+ } else {
+ #expect(hardwareActive)
+ #expect(collected.allSatisfy { $0.hardware })
+ #expect(collected.count >= 90, "4 s @ 24 fps ≈ 96 frames, got \(collected.count)")
+ }
// 10-bit stays 10-bit on either path.
#expect(Set(collected.map(\.format)).isSubset(of: [
kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange,