Skip to content

Commit 75341db

Browse files
diag(player): per-segment open-GOP witness for #92
Native DV Profile 8.1 HEVC shows transient blocky glitches on the loopback-HLS path, intermittent and varying per play, absent in direct-decode players, while P7 (same dvvC+db1p signaling) is clean. That signature points at non-independently-decodable segments rather than any static byte/signaling difference. The producer already drops HEVC RASL leading B-frames at the initial keyframe gate (open-GOP CRA), but mid-stream it routes by DTS and keeps each CRA together with its RASL leading pictures, whose PTS lands in the previous GOP. Those segments reference the prior segment and are not independently decodable; a fresh decode at such a boundary (rebuffer recovery) shows blocky corruption until the next clean keyframe. This adds a pure-logging witness (no behaviour change) to confirm the hypothesis on a real asset: - HEVCAccessUnitProbe: read-only AVCC NAL walk classifying a keyframe access unit as IDR vs CRA vs BLA and recognizing RASL/RADL slices. - HLSSegmentProducer (VOD + HEVC only): per-segment log of keyframe type, frame count, count of leading RASL pictures whose PTS precedes the segment keyframe, and an independent/NO verdict, plus a rolling aggregate. 8 unit tests for the probe. Full suite green (XCTest 220, Swift Testing 268), strict-concurrency build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XZTEfmztPE8hAdjHdBr9BH
1 parent 8e272f6 commit 75341db

3 files changed

Lines changed: 204 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Foundation
2+
3+
/// Read-only probe over an HEVC access unit in AVCC layout (length-prefixed NAL units).
4+
/// Used by the #92 open-GOP diagnostics: classify a segment's leading keyframe as IDR vs CRA vs
5+
/// BLA and recognize RASL/RADL leading-picture slice types. NAL-header inspection only, no slice
6+
/// parse, no allocation. AVCC layout matches DoviRpuConverter (4-byte big-endian length prefix).
7+
enum HEVCAccessUnitProbe {
8+
9+
// HEVC NAL unit types (H.265 Table 7-1), the subset we classify.
10+
static let nalRaslN: UInt8 = 8 // RASL_N: leading, dropped when NoRaslOutputFlag is set
11+
static let nalRaslR: UInt8 = 9 // RASL_R
12+
static let nalRadlN: UInt8 = 6
13+
static let nalRadlR: UInt8 = 7
14+
static let nalBlaWLp: UInt8 = 16
15+
static let nalIdrWRadl: UInt8 = 19
16+
static let nalIdrNLp: UInt8 = 20
17+
static let nalCra: UInt8 = 21
18+
19+
/// VCL NAL types are 0...31; non-VCL (VPS/SPS/PPS/SEI/AUD) are 32...63.
20+
static func isVCL(_ t: UInt8) -> Bool { t <= 31 }
21+
/// IRAP (random-access point) slice types: BLA 16...18, IDR 19...20, CRA 21 (16...23 inclusive).
22+
static func isIRAP(_ t: UInt8) -> Bool { t >= 16 && t <= 23 }
23+
static func isIDR(_ t: UInt8) -> Bool { t == 19 || t == 20 }
24+
static func isCRA(_ t: UInt8) -> Bool { t == 21 }
25+
static func isRASL(_ t: UInt8) -> Bool { t == 8 || t == 9 }
26+
27+
/// First VCL slice NAL type in the access unit, skipping non-VCL NALs (VPS/SPS/PPS/SEI/AUD).
28+
/// Returns nil when there is no VCL NAL or the buffer is malformed/truncated.
29+
static func firstSliceNALType(
30+
_ data: UnsafePointer<UInt8>, size: Int, lengthPrefixSize: Int = 4
31+
) -> UInt8? {
32+
var off = 0
33+
while off + lengthPrefixSize <= size {
34+
var len = 0
35+
for i in 0..<lengthPrefixSize { len = (len << 8) | Int(data[off + i]) }
36+
let nalStart = off + lengthPrefixSize
37+
if len <= 0 || nalStart + len > size { break }
38+
// HEVC NAL header byte0: forbidden_zero(1) | nal_unit_type(6) | nuh_layer_id MSB(1).
39+
let t = (data[nalStart] >> 1) & 0x3F
40+
if isVCL(t) { return t }
41+
off = nalStart + len
42+
}
43+
return nil
44+
}
45+
46+
static func firstSliceNALType(_ bytes: [UInt8], lengthPrefixSize: Int = 4) -> UInt8? {
47+
bytes.withUnsafeBufferPointer { buf in
48+
guard let base = buf.baseAddress else { return nil }
49+
return firstSliceNALType(base, size: buf.count, lengthPrefixSize: lengthPrefixSize)
50+
}
51+
}
52+
53+
/// Short label for diagnostics.
54+
static func label(forSliceType t: UInt8) -> String {
55+
switch t {
56+
case 19, 20: return "IDR"
57+
case 21: return "CRA"
58+
case 16, 17, 18: return "BLA"
59+
case 8, 9: return "RASL"
60+
case 6, 7: return "RADL"
61+
default: return isVCL(t) ? "TRAIL(\(t))" : "nonVCL(\(t))"
62+
}
63+
}
64+
}

Sources/AetherEngine/Video/HLSSegmentProducer.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,49 @@ final class HLSSegmentProducer: @unchecked Sendable {
291291
private var firstActualVideoPts: Int64 = Int64.min
292292
private var loggedFirstLeadingDrop: Bool = false
293293

294+
// #92 diagnostics (VOD, HEVC): per-segment open-GOP witness. Confirms whether mid-stream segment
295+
// keyframes are CRA (open-GOP) vs IDR, and whether segments carry RASL leading pictures whose PTS
296+
// precedes their own keyframe (making the segment non-independent). Pure logging, no behaviour change.
297+
private var dbg92Enabled: Bool = false
298+
private var dbg92CurSeg: Int = Int.min
299+
private var dbg92CurKfType: UInt8 = 0xFF
300+
private var dbg92SegKfPts: Int64 = Int64.min
301+
private var dbg92LeadingInSeg: Int = 0
302+
private var dbg92FramesInSeg: Int = 0
303+
private var dbg92IdrSegs: Int = 0
304+
private var dbg92CraSegs: Int = 0
305+
private var dbg92NonIndependentSegs: Int = 0
306+
private var dbg92SegsSeen: Int = 0
307+
308+
/// #92: flush the accumulated open-GOP witness for the segment that just ended. Logs every
309+
/// non-independent segment (CRA keyframe carrying RASL leading pictures, the smoking gun) plus the
310+
/// first few segments verbatim, and a rolling aggregate every 50 segments to bound log volume.
311+
private func emitDbg92SegmentSummary() {
312+
guard dbg92Enabled, dbg92CurSeg != Int.min else { return }
313+
dbg92SegsSeen += 1
314+
if HEVCAccessUnitProbe.isIDR(dbg92CurKfType) { dbg92IdrSegs += 1 }
315+
if HEVCAccessUnitProbe.isCRA(dbg92CurKfType) { dbg92CraSegs += 1 }
316+
let nonIndependent = dbg92LeadingInSeg > 0
317+
if nonIndependent { dbg92NonIndependentSegs += 1 }
318+
if dbg92SegsSeen <= 5 || nonIndependent {
319+
EngineLog.emit(
320+
"[HLSSegmentProducer] #92 seg-\(dbg92CurSeg) "
321+
+ "keyframe=\(HEVCAccessUnitProbe.label(forSliceType: dbg92CurKfType)) "
322+
+ "frames=\(dbg92FramesInSeg) leadingRASL=\(dbg92LeadingInSeg) "
323+
+ "independent=\(nonIndependent ? "NO" : "yes")",
324+
category: .session
325+
)
326+
}
327+
if dbg92SegsSeen % 50 == 0 {
328+
EngineLog.emit(
329+
"[HLSSegmentProducer] #92 aggregate: segs=\(dbg92SegsSeen) "
330+
+ "IDR=\(dbg92IdrSegs) CRA=\(dbg92CraSegs) "
331+
+ "nonIndependent=\(dbg92NonIndependentSegs)",
332+
category: .session
333+
)
334+
}
335+
}
336+
294337
/// Pre-gate drop counters; surface the "lädt unendlich" failure mode when the gate never opens.
295338
private var pregateVideoDropCount: Int = 0
296339
private var pregateWaitStart: Date?
@@ -547,6 +590,8 @@ final class HLSSegmentProducer: @unchecked Sendable {
547590
self.targetSegmentDurationSeconds = targetSegmentDurationSeconds
548591
self.segmentBoundaries = segmentBoundaries
549592
self.isLive = isLive
593+
// #92: open-GOP witness only matters for the VOD keyframe-plan path on HEVC sources.
594+
self.dbg92Enabled = !isLive && video.codecpar.pointee.codec_id == AV_CODEC_ID_HEVC
550595
self.liveCurrentSegmentIndex = baseIndex
551596
self.videoFallbackDurationPts = videoFallbackDurationPts
552597
self.audioFallbackDurationPts = audioFallbackDurationPts
@@ -1930,6 +1975,29 @@ final class HLSSegmentProducer: @unchecked Sendable {
19301975
category: .session
19311976
)
19321977
}
1978+
// #92: per-segment open-GOP witness. The first packet routed to a new VOD segment
1979+
// is that segment's boundary keyframe (kf.dts == kf.pts == plan boundary). Frames in
1980+
// the segment whose PTS precedes the keyframe PTS are RASL leading pictures referencing
1981+
// the previous GOP -> the segment is not independently decodable (see #92 analysis).
1982+
if dbg92Enabled {
1983+
if prevSeg != dbg92CurSeg {
1984+
emitDbg92SegmentSummary()
1985+
dbg92CurSeg = prevSeg
1986+
dbg92SegKfPts = prev.pointee.pts
1987+
dbg92FramesInSeg = 0
1988+
dbg92LeadingInSeg = 0
1989+
dbg92CurKfType = 0xFF
1990+
if let data = prev.pointee.data, prev.pointee.size > 0 {
1991+
dbg92CurKfType = HEVCAccessUnitProbe.firstSliceNALType(
1992+
data, size: Int(prev.pointee.size)) ?? 0xFF
1993+
}
1994+
}
1995+
dbg92FramesInSeg += 1
1996+
if dbg92SegKfPts != Int64.min, prev.pointee.pts != Int64.min,
1997+
prev.pointee.pts < dbg92SegKfPts {
1998+
dbg92LeadingInSeg += 1
1999+
}
2000+
}
19332001
if let muxer = ensureMuxer(forSegmentIndex: prevSeg) {
19342002
finalizeAndWriteVideo(prev, nextDts: packet.pointee.dts, muxer: muxer)
19352003
bumpPacketsWritten()
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import XCTest
2+
@testable import AetherEngine
3+
4+
final class HEVCAccessUnitProbeTests: XCTestCase {
5+
6+
/// Build one AVCC NAL (4-byte BE length prefix + 2-byte HEVC header + payload).
7+
private func nal(_ type: UInt8, payload: [UInt8] = [0x00]) -> [UInt8] {
8+
// byte0: forbidden_zero(0) | nal_unit_type(6) | nuh_layer_id MSB(0) = type << 1.
9+
let header: [UInt8] = [(type << 1) & 0x7E, 0x01]
10+
let body = header + payload
11+
let n = body.count
12+
let prefix: [UInt8] = [
13+
UInt8((n >> 24) & 0xFF), UInt8((n >> 16) & 0xFF),
14+
UInt8((n >> 8) & 0xFF), UInt8(n & 0xFF),
15+
]
16+
return prefix + body
17+
}
18+
19+
func testIDRAccessUnitWithParameterSets() {
20+
let au = nal(32) + nal(33) + nal(34) + nal(19) // VPS, SPS, PPS, IDR_W_RADL
21+
let t = HEVCAccessUnitProbe.firstSliceNALType(au)
22+
XCTAssertEqual(t, 19)
23+
XCTAssertTrue(HEVCAccessUnitProbe.isIDR(t!))
24+
XCTAssertTrue(HEVCAccessUnitProbe.isIRAP(t!))
25+
XCTAssertFalse(HEVCAccessUnitProbe.isCRA(t!))
26+
}
27+
28+
func testCRAAccessUnitSkipsLeadingSEI() {
29+
let au = nal(39) + nal(21) // SEI_PREFIX (non-VCL), CRA_NUT
30+
let t = HEVCAccessUnitProbe.firstSliceNALType(au)
31+
XCTAssertEqual(t, 21)
32+
XCTAssertTrue(HEVCAccessUnitProbe.isCRA(t!))
33+
XCTAssertTrue(HEVCAccessUnitProbe.isIRAP(t!))
34+
XCTAssertFalse(HEVCAccessUnitProbe.isIDR(t!))
35+
}
36+
37+
func testRASLLeadingPictureIsNotIRAP() {
38+
let t = HEVCAccessUnitProbe.firstSliceNALType(nal(9)) // RASL_R
39+
XCTAssertEqual(t, 9)
40+
XCTAssertTrue(HEVCAccessUnitProbe.isRASL(t!))
41+
XCTAssertFalse(HEVCAccessUnitProbe.isIRAP(t!))
42+
}
43+
44+
func testTrailingPictureIsNotIRAP() {
45+
let t = HEVCAccessUnitProbe.firstSliceNALType(nal(1)) // TRAIL_R
46+
XCTAssertEqual(t, 1)
47+
XCTAssertFalse(HEVCAccessUnitProbe.isIRAP(t!))
48+
XCTAssertFalse(HEVCAccessUnitProbe.isRASL(t!))
49+
}
50+
51+
func testNonVCLOnlyReturnsNil() {
52+
let au = nal(32) + nal(33) + nal(34) // VPS, SPS, PPS only
53+
XCTAssertNil(HEVCAccessUnitProbe.firstSliceNALType(au))
54+
}
55+
56+
func testTruncatedPrefixReturnsNil() {
57+
XCTAssertNil(HEVCAccessUnitProbe.firstSliceNALType([0x00, 0x00, 0x10]))
58+
}
59+
60+
func testLengthExceedingBufferStops() {
61+
// prefix claims 100 bytes; buffer holds 2 -> stop, nil.
62+
XCTAssertNil(HEVCAccessUnitProbe.firstSliceNALType([0x00, 0x00, 0x00, 0x64, 0x26, 0x01]))
63+
}
64+
65+
func testLabels() {
66+
XCTAssertEqual(HEVCAccessUnitProbe.label(forSliceType: 21), "CRA")
67+
XCTAssertEqual(HEVCAccessUnitProbe.label(forSliceType: 19), "IDR")
68+
XCTAssertEqual(HEVCAccessUnitProbe.label(forSliceType: 20), "IDR")
69+
XCTAssertEqual(HEVCAccessUnitProbe.label(forSliceType: 9), "RASL")
70+
XCTAssertEqual(HEVCAccessUnitProbe.label(forSliceType: 7), "RADL")
71+
}
72+
}

0 commit comments

Comments
 (0)