Skip to content

Commit 489e38c

Browse files
fix(hls): keyframe-gated VOD segment cut so the IRAP opens its segment (#92)
Root cause (confirmed via segverify vs ffmpeg ground truth): VOD routed each packet to a segment by its DTS against PTS-valued plan boundaries. Under B-frame reorder a keyframe's DTS is below its PTS, so the boundary IRAP fell into the previous segment and the next segment started mid-GOP, decode- dependent on its predecessor. AVPlayer hitting a fresh decode at such a boundary (rebuffer recovery) showed transient blocky corruption (the #92 report on native DV P8.1). The defect was general to reordered content, not just open-GOP CRA: closed-GOP-with-B-frames was equally affected. Fix: VODSegmentCutter cuts in decode order, keyframe-gated, like the existing live path and like FFmpeg's hls muxer. A segment opens at the IRAP that reaches its plan boundary, so the IRAP is the segment's first sample and its open-GOP RASL leading pictures (which follow in decode order) stay with it; a fresh decode then starts cleanly at the IRAP and discards RASL via NoRaslOutputFlag. Audio routing is unchanged (still presentation-aligned by PTS, as FFmpeg does). EXTINF still derives from the plan boundaries. Verified locally with `aetherctl segverify` on synthetic HEVC: before, 1/8 segments independently decodable (only seg0); after, 8/8 for BOTH open-GOP and closed-GOP, matching ffmpeg's own hls segmenter. 9 TDD tests for the cutter (RASL-stays-with-keyframe, intra-segment keyframe does not cut, base- index restart, sparse jump, end-boundary clamp). The #92 witness verdict now keys off "starts at IRAP" (trailing RASL are legitimate). Full suite green (XCTest 236, Swift Testing 268), strict-concurrency clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XZTEfmztPE8hAdjHdBr9BH
1 parent c70e1c2 commit 489e38c

3 files changed

Lines changed: 141 additions & 17 deletions

File tree

Sources/AetherEngine/Video/HLSSegmentProducer.swift

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,9 @@ final class HLSSegmentProducer: @unchecked Sendable {
245245
private var pendingVideoSegIndex: Int = 0
246246
private var pendingAudioSegIndex: Int = 0
247247

248+
/// VOD keyframe-gated cutter: opens each segment at the IRAP that reaches its plan boundary (#92).
249+
private var vodCutter: VODSegmentCutter
250+
248251
private var loggedFirstVideoPktInfo = false
249252
private var loggedP7ConversionFailure = false
250253
/// Latched false at SSAI program switch (ad creatives are H.264; mirrors muxer's isReinit ? false : videoConfig.convertP7ToProfile81).
@@ -313,14 +316,17 @@ final class HLSSegmentProducer: @unchecked Sendable {
313316
dbg92SegsSeen += 1
314317
if HEVCAccessUnitProbe.isIDR(dbg92CurKfType) { dbg92IdrSegs += 1 }
315318
if HEVCAccessUnitProbe.isCRA(dbg92CurKfType) { dbg92CraSegs += 1 }
316-
let nonIndependent = dbg92LeadingInSeg > 0
317-
if nonIndependent { dbg92NonIndependentSegs += 1 }
318-
if dbg92SegsSeen <= 5 || nonIndependent {
319+
// A segment is independently decodable iff its FIRST sample is an IRAP. Open-GOP RASL that follow
320+
// the IRAP in the same segment are fine (a fresh decode discards them via NoRaslOutputFlag); only
321+
// a segment that starts on a non-IRAP (the pre-fix mid-GOP cut) is dependent on its predecessor.
322+
let startsAtIRAP = HEVCAccessUnitProbe.isIRAP(dbg92CurKfType)
323+
if !startsAtIRAP { dbg92NonIndependentSegs += 1 }
324+
if dbg92SegsSeen <= 5 || !startsAtIRAP {
319325
EngineLog.emit(
320326
"[HLSSegmentProducer] #92 seg-\(dbg92CurSeg) "
321327
+ "keyframe=\(HEVCAccessUnitProbe.label(forSliceType: dbg92CurKfType)) "
322328
+ "frames=\(dbg92FramesInSeg) leadingRASL=\(dbg92LeadingInSeg) "
323-
+ "independent=\(nonIndependent ? "NO" : "yes")",
329+
+ "independent=\(startsAtIRAP ? "yes" : "NO")",
324330
category: .session
325331
)
326332
}
@@ -589,6 +595,7 @@ final class HLSSegmentProducer: @unchecked Sendable {
589595
self.sourceVideoTimeBase = video.timeBase
590596
self.targetSegmentDurationSeconds = targetSegmentDurationSeconds
591597
self.segmentBoundaries = segmentBoundaries
598+
self.vodCutter = VODSegmentCutter(boundaries: segmentBoundaries, baseIndex: baseIndex)
592599
self.isLive = isLive
593600
// #92: open-GOP witness only matters for the VOD keyframe-plan path on HEVC sources.
594601
self.dbg92Enabled = !isLive && video.codecpar.pointee.codec_id == AV_CODEC_ID_HEVC
@@ -1937,17 +1944,16 @@ final class HLSSegmentProducer: @unchecked Sendable {
19371944
}
19381945
}
19391946
// Live: keyframe cutter uses shifted pts. VOD: unused; routing uses prev.dts at look-behind site.
1947+
let isVideoKeyframe = (packet.pointee.flags & AV_PKT_FLAG_KEY) != 0
1948+
// VOD now cuts keyframe-gated like live (and like FFmpeg's hls muxer): a segment opens
1949+
// at the IRAP that reaches its plan boundary, so the IRAP is the segment's first sample
1950+
// and its open-GOP RASL leading pictures stay with it (#92). Routing by DTS against PTS
1951+
// boundaries used to drop the IRAP (dts < pts) into the previous segment.
19401952
let thisVideoSeg = isLive
1941-
? liveVideoSegmentIndex(
1942-
pts: packet.pointee.pts,
1943-
isKeyframe: (packet.pointee.flags & AV_PKT_FLAG_KEY) != 0
1944-
)
1945-
: 0
1953+
? liveVideoSegmentIndex(pts: packet.pointee.pts, isKeyframe: isVideoKeyframe)
1954+
: vodCutter.index(pts: packet.pointee.pts, isKeyframe: isVideoKeyframe)
19461955
if let prev = pendingVideoPkt {
1947-
// VOD: use DTS (not PTS) because HEVC open-GOP CRA leading B-frames have PTS in the previous segment.
1948-
let prevSeg = isLive
1949-
? pendingVideoSegIndex
1950-
: segmentIndex(forSourcePts: prev.pointee.dts)
1956+
let prevSeg = pendingVideoSegIndex
19511957
// #65 ledger: at each VOD segment open, map the segment's item-axis start (what AVPlayer and
19521958
// currentTime see) to the TRUE source content muxed there. drift = actual source - planned
19531959
// source for this index; non-zero means the presented frame leads the clock (Root B positively
@@ -2010,7 +2016,7 @@ final class HLSSegmentProducer: @unchecked Sendable {
20102016
}
20112017
}
20122018
pendingVideoPkt = packet
2013-
if isLive { pendingVideoSegIndex = thisVideoSeg }
2019+
pendingVideoSegIndex = thisVideoSeg // live: liveVideoSegmentIndex; VOD: keyframe-gated cutter
20142020
pktPtr = nil // ownership transferred to pendingVideoPkt
20152021
continue
20162022
}
@@ -2129,9 +2135,7 @@ final class HLSSegmentProducer: @unchecked Sendable {
21292135

21302136
// Flush look-behind; fallback duration produces tail-correct trun for the final fragment.
21312137
if let prev = pendingVideoPkt {
2132-
let prevSeg = isLive
2133-
? pendingVideoSegIndex
2134-
: segmentIndex(forSourcePts: prev.pointee.dts)
2138+
let prevSeg = pendingVideoSegIndex // unified: live + VOD both carry the routed index
21352139
if let muxer = ensureMuxer(forSegmentIndex: prevSeg) {
21362140
finalizeAndWriteVideo(prev, nextDts: nil, muxer: muxer)
21372141
bumpPacketsWritten()
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Foundation
2+
3+
/// Decode-order, keyframe-gated VOD segment cutter.
4+
///
5+
/// A new segment opens only when a keyframe whose presentation time reaches the next plan boundary
6+
/// arrives, so the IRAP becomes that segment's first sample and its open-GOP RASL leading pictures
7+
/// (which follow it in decode order) stay with it. This matches how FFmpeg's hls muxer and Apple's
8+
/// tools cut, and makes every segment start on a clean random-access point.
9+
///
10+
/// It replaces routing each packet by its DTS against PTS-valued boundaries: under B-frame reorder a
11+
/// keyframe's DTS is below its PTS, so `dts < boundary[N]` dropped the keyframe into segment N-1 and
12+
/// left segment N starting mid-GOP, decode-dependent on its predecessor (#92). Both open-GOP (CRA +
13+
/// RASL) and closed-GOP-with-B-frames were affected because the reorder delay is constant across the
14+
/// stream. The cut point is the only thing that changes; EXTINF still comes from the plan boundaries.
15+
struct VODSegmentCutter {
16+
17+
/// Plan boundaries in source PTS: `boundaries[i]` is the start PTS of segment `baseIndex + i`.
18+
/// `boundaries.count` is the segment count + 1 (the last entry is the end of the final segment).
19+
let boundaries: [Int64]
20+
let baseIndex: Int
21+
private(set) var current: Int
22+
23+
init(boundaries: [Int64], baseIndex: Int) {
24+
self.boundaries = boundaries
25+
self.baseIndex = baseIndex
26+
self.current = baseIndex
27+
}
28+
29+
/// Segment index for a video packet, in decode order. Advances on a keyframe that has reached the
30+
/// next boundary; every other packet (and an intra-segment keyframe that has not yet reached the
31+
/// next boundary, e.g. when the GOP is shorter than the segment) stays in the current segment.
32+
mutating func index(pts: Int64, isKeyframe: Bool) -> Int {
33+
guard isKeyframe, pts != Int64.min else { return current }
34+
// boundaries[count-1] is the end of the final segment, not a segment start, so the last segment
35+
// a keyframe can open is local (count-2): advance only while the next entry is a real start.
36+
var nextLocal = (current - baseIndex) + 1
37+
while nextLocal < boundaries.count - 1, pts >= boundaries[nextLocal] {
38+
current += 1
39+
nextLocal += 1
40+
}
41+
return current
42+
}
43+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import XCTest
2+
@testable import AetherEngine
3+
4+
final class VODSegmentCutterTests: XCTestCase {
5+
6+
// 4 segments of 4s each, boundaries in ms (source TB 1/1000): starts 0,4,8,12 + end 16.
7+
private let fourSeg: [Int64] = [0, 4000, 8000, 12000, 16000]
8+
9+
func testFirstKeyframeOpensBaseSegment() {
10+
var c = VODSegmentCutter(boundaries: fourSeg, baseIndex: 0)
11+
XCTAssertEqual(c.index(pts: 0, isKeyframe: true), 0) // first IRAP stays in seg 0
12+
}
13+
14+
func testNonKeyframesStayInCurrentSegment() {
15+
var c = VODSegmentCutter(boundaries: fourSeg, baseIndex: 0)
16+
_ = c.index(pts: 0, isKeyframe: true)
17+
XCTAssertEqual(c.index(pts: 1000, isKeyframe: false), 0)
18+
XCTAssertEqual(c.index(pts: 2000, isKeyframe: false), 0)
19+
}
20+
21+
func testBoundaryKeyframeOpensNextSegment() {
22+
var c = VODSegmentCutter(boundaries: fourSeg, baseIndex: 0)
23+
_ = c.index(pts: 0, isKeyframe: true)
24+
XCTAssertEqual(c.index(pts: 4000, isKeyframe: true), 1) // IRAP at 4s opens seg 1
25+
XCTAssertEqual(c.index(pts: 8000, isKeyframe: true), 2)
26+
XCTAssertEqual(c.index(pts: 12000, isKeyframe: true), 3)
27+
}
28+
29+
/// The #92 fix: open-GOP RASL leading pictures arrive in decode order AFTER the CRA but carry a PTS
30+
/// BEFORE it. They must stay in the CRA's segment, not be re-routed to the previous one.
31+
func testRaslLeadingPicturesStayWithTheirKeyframe() {
32+
var c = VODSegmentCutter(boundaries: fourSeg, baseIndex: 0)
33+
_ = c.index(pts: 0, isKeyframe: true)
34+
XCTAssertEqual(c.index(pts: 4000, isKeyframe: true), 1) // CRA opens seg 1
35+
XCTAssertEqual(c.index(pts: 3958, isKeyframe: false), 1) // RASL, pts < CRA -> stays in seg 1
36+
XCTAssertEqual(c.index(pts: 3917, isKeyframe: false), 1)
37+
XCTAssertEqual(c.index(pts: 4083, isKeyframe: false), 1) // trailing -> seg 1
38+
}
39+
40+
/// GOP shorter than the segment: an intra-segment keyframe that has not reached the next boundary
41+
/// must NOT open a new segment.
42+
func testIntraSegmentKeyframeDoesNotCut() {
43+
let twoSeg: [Int64] = [0, 8000, 16000] // 8s segments
44+
var c = VODSegmentCutter(boundaries: twoSeg, baseIndex: 0)
45+
_ = c.index(pts: 0, isKeyframe: true)
46+
XCTAssertEqual(c.index(pts: 4000, isKeyframe: true), 0) // mid-segment IRAP stays in seg 0
47+
XCTAssertEqual(c.index(pts: 8000, isKeyframe: true), 1) // boundary IRAP opens seg 1
48+
}
49+
50+
func testBaseIndexOffsetForRestart() {
51+
let b: [Int64] = [264_000, 268_000, 272_000, 276_000] // 3 segments: 264, 265, 266
52+
var c = VODSegmentCutter(boundaries: b, baseIndex: 264)
53+
XCTAssertEqual(c.index(pts: 264_000, isKeyframe: true), 264)
54+
XCTAssertEqual(c.index(pts: 268_000, isKeyframe: true), 265)
55+
XCTAssertEqual(c.index(pts: 272_000, isKeyframe: true), 266)
56+
}
57+
58+
func testSparseKeyframeJumpAdvancesMultiple() {
59+
var c = VODSegmentCutter(boundaries: fourSeg, baseIndex: 0)
60+
_ = c.index(pts: 0, isKeyframe: true)
61+
XCTAssertEqual(c.index(pts: 12000, isKeyframe: true), 3) // skips 1,2 in one step
62+
}
63+
64+
func testNeverAdvancesPastLastSegment() {
65+
var c = VODSegmentCutter(boundaries: fourSeg, baseIndex: 0)
66+
_ = c.index(pts: 0, isKeyframe: true)
67+
// Keyframes well past the final boundary clamp at the last segment (count-1 boundaries => seg 3).
68+
XCTAssertEqual(c.index(pts: 99_000, isKeyframe: true), 3)
69+
XCTAssertEqual(c.index(pts: 99_000, isKeyframe: true), 3)
70+
}
71+
72+
func testNoptsKeyframeStaysPut() {
73+
var c = VODSegmentCutter(boundaries: fourSeg, baseIndex: 0)
74+
_ = c.index(pts: 0, isKeyframe: true)
75+
XCTAssertEqual(c.index(pts: Int64.min, isKeyframe: true), 0)
76+
}
77+
}

0 commit comments

Comments
 (0)