Skip to content

Commit e5e5d0b

Browse files
Merge pull request #415 from superuser404notfound/fix/409-held-packets-survive-seek
A seek drops the packets the repair verdict left held (#409 follow-up)
2 parents f08db21 + 308b08b commit e5e5d0b

2 files changed

Lines changed: 108 additions & 8 deletions

File tree

Sources/AetherEngine/Video/H264CompositionOffsetRepair.swift

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -434,17 +434,17 @@ final class H264CompositionOffsetRepairSession {
434434
}
435435

436436
func noteSeek() {
437+
// The held packets belong to the position that was abandoned, in EVERY phase, not just while
438+
// the sample is still open: a settled verdict leaves the sample it read in the queue, and
439+
// `dequeue()` hands that queue out ahead of anything read after the seek. Dropping it only
440+
// during `.sampling` republished the old position's packets at the landing, which on a healthy
441+
// file (verdict `.off`, sample still held) put two duplicate pictures into the stream a
442+
// producer had already emitted.
443+
dropHeldPackets()
437444
switch phase {
438445
case .sampling:
439-
// The held packets belong to the position that was abandoned; the sample restarts where
440-
// the source now stands.
441-
for entry in held {
442-
var owned: UnsafeMutablePointer<AVPacket>? = entry.packet
443-
trackedPacketFree(&owned)
444-
}
445-
held.removeAll(keepingCapacity: true)
446+
// The sample restarts where the source now stands.
446447
samples.removeAll(keepingCapacity: true)
447-
heldBytes = 0
448448
case .repairing:
449449
rewriter?.noteSeek()
450450
reader?.reset()
@@ -453,6 +453,15 @@ final class H264CompositionOffsetRepairSession {
453453
}
454454
}
455455

456+
private func dropHeldPackets() {
457+
for entry in held {
458+
var owned: UnsafeMutablePointer<AVPacket>? = entry.packet
459+
trackedPacketFree(&owned)
460+
}
461+
held.removeAll(keepingCapacity: true)
462+
heldBytes = 0
463+
}
464+
456465
/// Puts a packet at the head of the queue. Used by the demuxer when a sample ends on a packet
457466
/// the session did not take over, so decode order survives the handover.
458467
func enqueueFront(_ packet: UnsafeMutablePointer<AVPacket>) {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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

Comments
 (0)