Skip to content

Commit d2fc7f2

Browse files
Merge fix/365-annexb-sei-leak: the SEI leaves the record before the muxer builds the hvcC (#365)
2 parents 71e6f09 + f6201cf commit d2fc7f2

3 files changed

Lines changed: 282 additions & 3 deletions

File tree

Sources/AetherEngine/Video/HLSVideoEngine+SegmentPlanning.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -552,13 +552,27 @@ extension HLSVideoEngine {
552552

553553
let framing = probeVideoNALFraming(demuxer: demuxer, videoStreamIndex: videoStreamIndex)
554554
guard case .lengthPrefixed = framing else {
555+
// The record stays Annex B here: movenc reads it to decide whether to convert the samples,
556+
// and these samples do need converting. What it must not keep is a prefix SEI, because the
557+
// hvcC movenc then builds carries it as a fourth array (`ff_isom_write_hvcc` collects five
558+
// NAL types) and Apple TV's HEVC track builder rejects such a record (AE#187). That defense
559+
// sits on the record path and cannot see this one, so the SEI goes before the muxer runs.
560+
let source = codecpar.pointee.extradata.map {
561+
[UInt8](UnsafeBufferPointer(start: $0, count: Int(codecpar.pointee.extradata_size)))
562+
} ?? []
563+
let canonical = codecID == AV_CODEC_ID_HEVC
564+
? VideoConfigRecord.canonicalizeAnnexBHEVCConfigRecord(source) : nil
555565
EngineLog.emit(
556566
"[HLSVideoEngine] #365 the muxer will reformat this track's samples and the packets "
557-
+ "are \(framing == nil ? "not conclusively framed" : "Annex B"); forwarding the "
558-
+ "config record unchanged (the muxer converts the samples itself)",
567+
+ "are \(framing == nil ? "not conclusively framed" : "Annex B"); the muxer builds the "
568+
+ "config record itself out of \(source.count) B of Annex B "
569+
+ "[\(VideoConfigRecord.annexBNALSummary(source))]"
570+
+ (canonical.map {
571+
", dropped the non-parameter-set NALs before it does (→ \($0.count) B, AE#187)"
572+
} ?? ", nothing to drop"),
559573
category: .session
560574
)
561-
return VideoFramingNormalization(extradataOverride: nil, measuredFraming: framing)
575+
return VideoFramingNormalization(extradataOverride: canonical, measuredFraming: framing)
562576
}
563577

564578
let source = [UInt8](UnsafeBufferPointer(

Sources/AetherEngine/Video/VideoConfigRecord.swift

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,99 @@ enum VideoConfigRecord {
119119
return extractBox(fourCC: boxFourCC, from: bytes, count: Int(written))
120120
}
121121

122+
/// Keep only VPS/SPS/PPS in an Annex-B HEVC config record, still in Annex B.
123+
///
124+
/// When the packets are Annex B too, the record has to be forwarded as it is: movenc reads it to
125+
/// decide whether to convert the samples, and handing it an `hvcC` here would leave every sample
126+
/// unconverted. It then builds the `hvcC` itself, and `ff_isom_write_hvcc` collects five NAL types,
127+
/// not three (`array_idx_to_type`: VPS, SPS, PPS, SEI_PREFIX, SEI_SUFFIX), so a prefix SEI in the
128+
/// CodecPrivate reaches the init sample description as a fourth array. That is the record Apple TV
129+
/// hardware rejects (AE#187), and the AE#187 defense cannot see it: `canonicalizeHEVCConfigRecord`
130+
/// guards on `configurationVersion == 1`, which an Annex-B buffer fails by construction, and the
131+
/// muxer-built record is never handled by the engine at all. Dropping the SEI on this side of the
132+
/// muxer is the only place the two constraints both hold.
133+
///
134+
/// NALs are re-emitted with 3-byte start codes, the shape libavformat synthesises for a Matroska
135+
/// track whose CodecPrivate is Annex B. Returns nil when the buffer is not Annex B, carries no
136+
/// parameter sets to keep, or has nothing to drop; the caller then forwards the source unchanged.
137+
static func canonicalizeAnnexBHEVCConfigRecord(_ extradata: [UInt8]) -> [UInt8]? {
138+
guard isAnnexB(extradata) else { return nil }
139+
let nals = splitAnnexBNALs(extradata)
140+
guard !nals.isEmpty else { return nil }
141+
142+
let parameterSetTypes: Set<Int> = [32, 33, 34] // VPS, SPS, PPS
143+
let kept = nals.filter { parameterSetTypes.contains(hevcNALType($0)) }
144+
guard kept.count < nals.count else { return nil } // nothing to drop: already canonical
145+
guard kept.contains(where: { hevcNALType($0) == 33 }) // no SPS means no record at all;
146+
else { return nil } // forwarding is worse than nothing
147+
148+
var out: [UInt8] = []
149+
out.reserveCapacity(kept.reduce(0) { $0 + $1.count + 3 })
150+
for nal in kept { out += [0x00, 0x00, 0x01]; out += nal }
151+
return out
152+
}
153+
154+
/// What an Annex-B config record is made of, for the log.
155+
///
156+
/// The composition is the discriminating fact when a source with an Annex-B record fails to build a
157+
/// format description, and it is the one thing the reporter's own capture can carry: the record
158+
/// itself is not in any log, its size alone does not say whether the excess is a large SPS or an
159+
/// SEI, and only the latter reaches the muxer-built hvcC.
160+
static func annexBNALSummary(_ extradata: [UInt8]) -> String {
161+
var order: [Int] = []
162+
var counts: [Int: (count: Int, bytes: Int)] = [:]
163+
for nal in splitAnnexBNALs(extradata) {
164+
let type = hevcNALType(nal)
165+
if counts[type] == nil { order.append(type) }
166+
let existing = counts[type] ?? (0, 0)
167+
counts[type] = (existing.count + 1, existing.bytes + nal.count)
168+
}
169+
return order.map { type in
170+
let entry = counts[type]!
171+
return "\(hevcNALTypeName(type))×\(entry.count) (\(entry.bytes) B)"
172+
}.joined(separator: ", ")
173+
}
174+
175+
private static func hevcNALTypeName(_ type: Int) -> String {
176+
switch type {
177+
case 32: return "VPS"
178+
case 33: return "SPS"
179+
case 34: return "PPS"
180+
case 39: return "SEI_PREFIX"
181+
case 40: return "SEI_SUFFIX"
182+
default: return "NAL\(type)"
183+
}
184+
}
185+
186+
private static func hevcNALType(_ nal: [UInt8]) -> Int {
187+
nal.isEmpty ? -1 : (Int(nal[0]) >> 1) & 0x3F
188+
}
189+
190+
/// Split an Annex-B buffer into NAL payloads, 3-byte and 4-byte start codes alike.
191+
///
192+
/// Zero bytes immediately before a start code belong to the start code (a 4-byte code is a 3-byte
193+
/// one with a leading `trailing_zero_8bits`), so they are trimmed off the preceding NAL. A
194+
/// parameter set never ends in `0x00`: its last byte carries the rbsp_stop_one_bit.
195+
private static func splitAnnexBNALs(_ bytes: [UInt8]) -> [[UInt8]] {
196+
var starts: [Int] = []
197+
var i = 0
198+
while i + 3 <= bytes.count {
199+
if bytes[i] == 0, bytes[i + 1] == 0, bytes[i + 2] == 1 {
200+
starts.append(i + 3)
201+
i += 3
202+
} else {
203+
i += 1
204+
}
205+
}
206+
var out: [[UInt8]] = []
207+
for (idx, start) in starts.enumerated() {
208+
var end = idx + 1 < starts.count ? starts[idx + 1] - 3 : bytes.count
209+
while end > start, bytes[end - 1] == 0 { end -= 1 }
210+
if end > start { out.append(Array(bytes[start..<end])) }
211+
}
212+
return out
213+
}
214+
122215
/// First payload of a top-level-or-nested box with this four-character code. The probe header is
123216
/// a few hundred bytes and has exactly one such box, so a linear scan is both sufficient and the
124217
/// only thing that stays correct if movenc ever changes where it nests the sample entry.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import Testing
2+
import Libavcodec
3+
@testable import AetherEngine
4+
5+
/// #365 round 2: when the source config record is Annex B and the packets are Annex B too, the record
6+
/// is forwarded and **movenc builds the hvcC itself**. `ff_isom_write_hvcc` collects five NAL types,
7+
/// not three (`array_idx_to_type` in libavformat/hevc.c: VPS, SPS, PPS, SEI_PREFIX, SEI_SUFFIX), so a
8+
/// prefix SEI in the CodecPrivate lands in the init sample description. That is exactly the record
9+
/// Apple TV hardware rejects (AE#187), and `canonicalizeHEVCConfigRecord` cannot defend this door: it
10+
/// guards on `configurationVersion == 1`, which an Annex-B buffer fails by construction, and the
11+
/// muxer-built record never passes through the engine at all.
12+
struct Issue365AnnexBSEILeakTests {
13+
14+
/// VPS / SPS / PPS of a real 1080p Main10 PQ stream, Annex B with 3-byte start codes.
15+
private static let parameterSets: [UInt8] = [
16+
0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x02, 0x20, 0x00, 0x00, 0x03, 0x00,
17+
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x78, 0x95, 0x94, 0x09, 0x00, 0x00, 0x01,
18+
0x42, 0x01, 0x01, 0x02, 0x20, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00,
19+
0x03, 0x00, 0x78, 0xa0, 0x03, 0xc0, 0x80, 0x11, 0x07, 0xca, 0xd9, 0x65, 0x65, 0x4a, 0x4c,
20+
0x2f, 0x01, 0x6a, 0x12, 0x20, 0x12, 0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03,
21+
0x00, 0xc0, 0x40, 0x00, 0x00, 0x01, 0x44, 0x01, 0xc0, 0x73, 0xc1, 0x89,
22+
]
23+
24+
/// A prefix SEI (NAL type 39) carrying an unregistered user-data payload, the shape x265 writes its
25+
/// options string in. 500 payload bytes so the blob lands near the 726 B the #365 reporter's
26+
/// CodecPrivate carries, which is far more than VPS + SPS + PPS can account for on their own.
27+
private static func userDataSEI(payloadBytes: Int = 500) -> [UInt8] {
28+
var nal: [UInt8] = [0x4E, 0x01] // nal_type 39, nuh_layer_id 0, temporal_id_plus1 1
29+
nal.append(0x05) // payloadType: user_data_unregistered
30+
var remaining = payloadBytes
31+
while remaining >= 255 { nal.append(0xFF); remaining -= 255 }
32+
nal.append(UInt8(remaining))
33+
nal += [UInt8](repeating: 0x42, count: payloadBytes) // 0x42 filler cannot emulate a start code
34+
nal.append(0x80) // rbsp_trailing_bits
35+
return [0x00, 0x00, 0x01] + nal
36+
}
37+
38+
/// NAL types of the arrays in an hvcC, in the order the record lists them.
39+
private static func hvcCArrayTypes(_ record: [UInt8]) -> [Int] {
40+
guard record.count >= 23, record[0] == 1 else { return [] }
41+
var types: [Int] = []
42+
var offset = 23
43+
for _ in 0..<Int(record[22]) {
44+
guard offset + 3 <= record.count else { return types }
45+
types.append(Int(record[offset]) & 0x3F)
46+
let numNalus = (Int(record[offset + 1]) << 8) | Int(record[offset + 2])
47+
offset += 3
48+
for _ in 0..<numNalus {
49+
guard offset + 2 <= record.count else { return types }
50+
offset += 2 + ((Int(record[offset]) << 8) | Int(record[offset + 1]))
51+
}
52+
}
53+
return types
54+
}
55+
56+
private static func splitAnnexB(_ bytes: [UInt8]) -> [[UInt8]] {
57+
var starts: [Int] = []
58+
var i = 0
59+
while i + 3 <= bytes.count {
60+
if bytes[i] == 0, bytes[i + 1] == 0, bytes[i + 2] == 1 {
61+
starts.append(i + 3)
62+
i += 3
63+
} else {
64+
i += 1
65+
}
66+
}
67+
return starts.enumerated().map { idx, start in
68+
let end = idx + 1 < starts.count ? starts[idx + 1] - 3 : bytes.count
69+
return Array(bytes[start..<end])
70+
}
71+
}
72+
73+
private static func nalTypes(_ annexB: [UInt8]) -> [Int] {
74+
splitAnnexB(annexB).map { (Int($0[0]) >> 1) & 0x3F }
75+
}
76+
77+
// MARK: - The leak, measured on the muxer rather than assumed
78+
79+
@Test("movenc writes the prefix SEI into the hvcC it builds from an Annex-B record")
80+
func muxerBuiltRecordCarriesTheSEIArray() throws {
81+
let withSEI = Self.parameterSets + Self.userDataSEI()
82+
let record = try #require(VideoConfigRecord.fromAnnexB(
83+
withSEI, codecID: AV_CODEC_ID_HEVC, width: 1920, height: 1080))
84+
// Four arrays, the fourth being SEI_PREFIX: this is the AE#187 record shape, reached through a
85+
// door the AE#187 defense does not cover.
86+
#expect(Self.hvcCArrayTypes(record) == [32, 33, 34, 39])
87+
}
88+
89+
// MARK: - The fix
90+
91+
@Test("Canonicalizing the Annex-B record drops the SEI and keeps VPS/SPS/PPS")
92+
func canonicalizationDropsTheSEINAL() throws {
93+
let withSEI = Self.parameterSets + Self.userDataSEI()
94+
let canonical = try #require(VideoConfigRecord.canonicalizeAnnexBHEVCConfigRecord(withSEI))
95+
96+
#expect(Self.nalTypes(canonical) == [32, 33, 34])
97+
// Still Annex B, so movenc makes the same call about the samples it made before: these packets
98+
// are Annex B and have to be converted. Handing it an hvcC here would leave them unconverted.
99+
#expect(VideoConfigRecord.isAnnexB(canonical))
100+
// The parameter sets themselves are untouched, byte for byte.
101+
#expect(Self.splitAnnexB(canonical) == Self.splitAnnexB(Self.parameterSets))
102+
}
103+
104+
@Test("The record movenc builds from the canonicalized blob has no SEI array")
105+
func canonicalizedRecordSurvivesTheMuxer() throws {
106+
let withSEI = Self.parameterSets + Self.userDataSEI()
107+
let canonical = try #require(VideoConfigRecord.canonicalizeAnnexBHEVCConfigRecord(withSEI))
108+
let record = try #require(VideoConfigRecord.fromAnnexB(
109+
canonical, codecID: AV_CODEC_ID_HEVC, width: 1920, height: 1080))
110+
111+
#expect(Self.hvcCArrayTypes(record) == [32, 33, 34])
112+
#expect(record[22] == 3)
113+
}
114+
115+
@Test("Also drops a suffix SEI and leaves an unknown NAL type out")
116+
func dropsSuffixSEIAndUnknownTypes() throws {
117+
var blob = Self.parameterSets
118+
blob += [0x00, 0x00, 0x01, 0x50, 0x01, 0x03, 0x04, 0x80] // nal_type 40, SEI_SUFFIX
119+
blob += [0x00, 0x00, 0x01, 0x7C, 0x01, 0x11, 0x22] // nal_type 62, unspecified (DV RPU)
120+
let canonical = try #require(VideoConfigRecord.canonicalizeAnnexBHEVCConfigRecord(blob))
121+
#expect(Self.nalTypes(canonical) == [32, 33, 34])
122+
}
123+
124+
@Test("A record that is already parameter-sets-only returns nil (nothing to rewrite)")
125+
func alreadyCanonicalReturnsNil() {
126+
#expect(VideoConfigRecord.canonicalizeAnnexBHEVCConfigRecord(Self.parameterSets) == nil)
127+
}
128+
129+
@Test("An hvcC is refused: this canonicalizer only speaks Annex B")
130+
func refusesAnHvcC() throws {
131+
let record = try #require(VideoConfigRecord.fromAnnexB(
132+
Self.parameterSets, codecID: AV_CODEC_ID_HEVC, width: 1920, height: 1080))
133+
#expect(VideoConfigRecord.canonicalizeAnnexBHEVCConfigRecord(record) == nil)
134+
}
135+
136+
@Test("A blob with no parameter sets at all is left alone rather than emptied")
137+
func refusesToEmitAnEmptyRecord() {
138+
#expect(VideoConfigRecord.canonicalizeAnnexBHEVCConfigRecord(Self.userDataSEI()) == nil)
139+
}
140+
141+
// MARK: - The witness
142+
143+
@Test("The Annex-B summary names every NAL type and what it costs in bytes")
144+
func summaryNamesTypesAndSizes() {
145+
var blob: [UInt8] = []
146+
blob += [0, 0, 1, 0x40, 0x01] + [UInt8](repeating: 0x11, count: 20) // VPS, 22 B
147+
blob += [0, 0, 1, 0x42, 0x01] + [UInt8](repeating: 0x22, count: 50) // SPS, 52 B
148+
blob += [0, 0, 1, 0x44, 0x01] + [UInt8](repeating: 0x33, count: 5) // PPS, 7 B
149+
blob += [0, 0, 1, 0x4E, 0x01] + [UInt8](repeating: 0x44, count: 400) // SEI_PREFIX, 402 B
150+
#expect(VideoConfigRecord.annexBNALSummary(blob)
151+
== "VPS×1 (22 B), SPS×1 (52 B), PPS×1 (7 B), SEI_PREFIX×1 (402 B)")
152+
}
153+
154+
@Test("Repeated NAL types are counted together, unknown types are named by number")
155+
func summaryCountsRepeatsAndNamesUnknownTypes() {
156+
var blob: [UInt8] = []
157+
blob += [0, 0, 1, 0x42, 0x01] + [UInt8](repeating: 0x22, count: 10) // SPS, 12 B
158+
blob += [0, 0, 1, 0x42, 0x01] + [UInt8](repeating: 0x22, count: 20) // SPS, 22 B
159+
blob += [0, 0, 1, 0x7C, 0x01] + [UInt8](repeating: 0x33, count: 4) // type 62, 6 B
160+
#expect(VideoConfigRecord.annexBNALSummary(blob) == "SPS×2 (34 B), NAL62×1 (6 B)")
161+
}
162+
163+
@Test("Four-byte start codes are handled the same as three-byte ones")
164+
func handlesFourByteStartCodes() throws {
165+
var blob: [UInt8] = []
166+
for nal in Self.splitAnnexB(Self.parameterSets) { blob += [0x00, 0x00, 0x00, 0x01] + nal }
167+
blob += [0x00, 0x00, 0x00, 0x01] + Array(Self.userDataSEI().dropFirst(3))
168+
let canonical = try #require(VideoConfigRecord.canonicalizeAnnexBHEVCConfigRecord(blob))
169+
#expect(Self.nalTypes(canonical) == [32, 33, 34])
170+
#expect(VideoConfigRecord.isAnnexB(canonical))
171+
}
172+
}

0 commit comments

Comments
 (0)