|
| 1 | +import Foundation |
| 2 | +import Testing |
| 3 | +import Libavcodec |
| 4 | +@testable import AetherEngine |
| 5 | + |
| 6 | +/// #409 follow-up. The repair settles its verdict by reading ahead and HOLDING what it read, so the |
| 7 | +/// container's own order survives the decision. `noteSeek()` dropped that queue only while the sample was |
| 8 | +/// still open: once the verdict had landed (a healthy file decides `.off` on its first packet, with the |
| 9 | +/// sample still held), the queue outlived the seek and `dequeue()` handed the abandoned position's packets |
| 10 | +/// out ahead of the landing's. On the producer path that put duplicate pictures into a stream it had |
| 11 | +/// already emitted, which `Issue259A53CaptionAxisTests` sees as two repeated A/53 observations. |
| 12 | +private func fixtureURL(_ name: String) -> URL { |
| 13 | + URL(fileURLWithPath: #filePath) |
| 14 | + .deletingLastPathComponent() |
| 15 | + .deletingLastPathComponent() |
| 16 | + .deletingLastPathComponent() |
| 17 | + .appendingPathComponent("Fixtures") |
| 18 | + .appendingPathComponent(name) |
| 19 | +} |
| 20 | + |
| 21 | +private func fixtureExists(_ name: String) -> Bool { |
| 22 | + FileManager.default.fileExists(atPath: fixtureURL(name).path) |
| 23 | +} |
| 24 | + |
| 25 | +@Suite("Held packets and seeks (#409)") |
| 26 | +struct Issue409HeldPacketSeekTests { |
| 27 | + |
| 28 | + private static func videoPTS(_ dem: Demuxer, count: Int) throws -> [Int64] { |
| 29 | + var out: [Int64] = [] |
| 30 | + while out.count < count, let pkt = try dem.readPacket() { |
| 31 | + if Int(pkt.pointee.stream_index) == dem.videoStreamIndex, pkt.pointee.pts != Int64.min { |
| 32 | + out.append(pkt.pointee.pts) |
| 33 | + } |
| 34 | + var owned: UnsafeMutablePointer<AVPacket>? = pkt |
| 35 | + trackedPacketFree(&owned) |
| 36 | + } |
| 37 | + return out |
| 38 | + } |
| 39 | + |
| 40 | + @Test("a seek drops the sample the verdict left held", |
| 41 | + .enabled(if: fixtureExists("a53-captions.mp4"), |
| 42 | + "run Scripts/fetch-fixtures.sh to generate the A/53 caption clip"), |
| 43 | + .timeLimit(.minutes(2))) |
| 44 | + func heldSampleDoesNotSurviveASeek() throws { |
| 45 | + let url = fixtureURL("a53-captions.mp4") |
| 46 | + |
| 47 | + // The reference arm never samples, so what it delivers after the seek is what the container |
| 48 | + // holds there. Comparing against it makes the pin independent of where the seek lands, which on |
| 49 | + // a fixture with one IDR is the head itself. |
| 50 | + let reference = Demuxer() |
| 51 | + try reference.open(url: url, extraHeaders: [:], profile: .playback, isLive: false) |
| 52 | + defer { reference.close() } |
| 53 | + #expect(reference.seek(to: 1.0), "fixture must be seekable") |
| 54 | + let expected = try Self.videoPTS(reference, count: 24) |
| 55 | + #expect(expected.count == 24, "fixture must deliver a landing to compare against") |
| 56 | + |
| 57 | + let decided = Demuxer() |
| 58 | + try decided.open(url: url, extraHeaders: [:], profile: .playback, isLive: false) |
| 59 | + defer { decided.close() } |
| 60 | + // The engine settles the verdict at the head, before the cue prewarm, and only then does |
| 61 | + // anything seek (`HLSVideoEngine.prepare`). This is that order. |
| 62 | + decided.decideCompositionOffsetRepair() |
| 63 | + #expect(decided.seek(to: 1.0)) |
| 64 | + let observed = try Self.videoPTS(decided, count: 24) |
| 65 | + |
| 66 | + #expect(observed == expected, |
| 67 | + "the landing re-served packets the verdict was still holding: \(observed) vs \(expected)") |
| 68 | + } |
| 69 | + |
| 70 | + @Test("without a seek the held sample is still delivered in full", |
| 71 | + .enabled(if: fixtureExists("a53-captions.mp4"), |
| 72 | + "run Scripts/fetch-fixtures.sh to generate the A/53 caption clip"), |
| 73 | + .timeLimit(.minutes(2))) |
| 74 | + func heldSampleSurvivesWhenNothingSeeks() throws { |
| 75 | + let url = fixtureURL("a53-captions.mp4") |
| 76 | + |
| 77 | + let plain = Demuxer() |
| 78 | + try plain.open(url: url, extraHeaders: [:], profile: .playback, isLive: false) |
| 79 | + defer { plain.close() } |
| 80 | + let expected = try Self.videoPTS(plain, count: 12) |
| 81 | + |
| 82 | + let decided = Demuxer() |
| 83 | + try decided.open(url: url, extraHeaders: [:], profile: .playback, isLive: false) |
| 84 | + defer { decided.close() } |
| 85 | + decided.decideCompositionOffsetRepair() |
| 86 | + let observed = try Self.videoPTS(decided, count: 12) |
| 87 | + |
| 88 | + #expect(observed == expected, |
| 89 | + "deciding the verdict must not cost a packet: \(observed) vs \(expected)") |
| 90 | + } |
| 91 | +} |
0 commit comments