Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ the public-API contract.

### Fixed

- **A complete HEVC length chain with an incomplete two-byte NAL header no longer poisons an fMP4
segment** (#960). The muxer removes only short headers, preserves the surrounding pictures/RPUs and
timestamps, and drops a sample consisting entirely of short headers. Packet compaction is copy-on-write.

- **WMV1/2 software packet caches retain continuous presentation coverage.** Their I/P-only
streams use the existing successor-PTS model with depth one, avoiding millisecond holes
from rounded Matroska durations. Disk and memory stores share the qualification; missing/late
Expand Down
43 changes: 43 additions & 0 deletions Sources/AetherEngine/Video/MP4SegmentMuxer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ final class MP4SegmentMuxer {
/// length-prefixed at all. Latched at init: it is a property of the configuration record that
/// lands in the sample entry, and the AE#561 sanitizer walks every video sample with it.
private let videoNALLengthPrefixSize: Int?
private let isHEVCVideo: Bool
private var shortHEVCHeadersRemoved = 0
/// Latched from `VideoConfig.convertsAnnexBSamples`.
private let convertsAnnexBVideoSamples: Bool
/// AE#561 harness switch: the sanitizer removes the only shape that reproduces a segment Apple's
Expand Down Expand Up @@ -328,6 +330,7 @@ final class MP4SegmentMuxer {
audio.map { Self.audioNeedsParsedPacketForMoov($0.codecpar.pointee.codec_id) } ?? false
self.videoNALFraming = video.nalFramingLatch ?? NALFramingLatch()
self.convertsAnnexBVideoSamples = video.convertsAnnexBSamples
self.isHEVCVideo = video.codecpar.pointee.codec_id == AV_CODEC_ID_HEVC
// AE#561: the override, when there is one, is the record that reaches the sample entry. Both
// carry the same width (the #19 rebuild keeps the source header's first 22 bytes), so this
// only matters for a source whose own extradata is missing or Annex B.
Expand Down Expand Up @@ -778,6 +781,46 @@ final class MP4SegmentMuxer {
av_shrink_packet(packet, Int32(complete))
}

if streamIndex == videoOutputStreamIndex, isHEVCVideo,
!Self.nalChainSanitizerDisabled, let width = videoNALLengthPrefixSize,
let data = packet.pointee.data, packet.pointee.size > 0
{
let ranges = NALUnitChain.shortHEVCHeaderRanges(
UnsafeRawBufferPointer(start: data, count: Int(packet.pointee.size)),
lengthPrefixSize: width,
framingConfirmed: convertsAnnexBVideoSamples || videoNALFraming.isConfirmed)
if !ranges.isEmpty {
let result = av_packet_make_writable(packet)
guard result >= 0, let writable = packet.pointee.data else { return (result, .none) }
let count = Int(packet.pointee.size)
var read = 0
var write = 0
for range in ranges {
let kept = range.lowerBound - read
if kept > 0 {
memmove(writable + write, writable + read, kept)
write += kept
}
read = range.upperBound
}
if read < count {
memmove(writable + write, writable + read, count - read)
write += count - read
}
shortHEVCHeadersRemoved += ranges.count
if shortHEVCHeadersRemoved <= 5 || shortHEVCHeadersRemoved % 100 == 0 {
EngineLog.emit(
"[MP4SegmentMuxer] removed \(ranges.count) incomplete HEVC NAL header(s) "
+ "at dts=\(packet.pointee.dts), preserving \(write) sample bytes", category: .session)
}
av_shrink_packet(packet, Int32(write))
if write == 0 {
av_packet_unref(packet)
return (0, .none)
}
}
}

// The packet is consumed by the write below.
let writtenDuration = max(packet.pointee.duration, 0)

Expand Down
29 changes: 29 additions & 0 deletions Sources/AetherEngine/Video/NALUnitChain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,33 @@ enum NALUnitChain {
guard let base = bytes.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return false }
return VideoConfigRecord.walksAsLengthPrefixed(base, size: bytes.count, lengthSize: lengthPrefixSize)
}

/// HEVC's NAL header is two bytes, even for EOS/EOB. A one-byte EOS from a
/// truncated transport-stream tail can have a perfectly valid length prefix,
/// so the overrun guard above cannot catch it. Apple's parser refuses the
/// entire HLS segment, while libavcodec ignores the damaged delimiter.
/// Return only the malformed units, preserving valid pictures and RPUs after
/// them. Never treat an unconfirmed Annex-B start code as a length of one.
static func shortHEVCHeaderRanges(
_ bytes: UnsafeRawBufferPointer, lengthPrefixSize: Int, framingConfirmed: Bool
) -> [Range<Int>] {
guard (1...4).contains(lengthPrefixSize), bytes.count >= lengthPrefixSize else { return [] }
if !framingConfirmed, bytes.count >= 3, bytes[0] == 0, bytes[1] == 0,
bytes[2] == 1 || (bytes.count >= 4 && bytes[2] == 0 && bytes[3] == 1)
{
return []
}
var ranges: [Range<Int>] = []
var offset = 0
while offset + lengthPrefixSize <= bytes.count {
var length = 0
for index in 0..<lengthPrefixSize { length = (length << 8) | Int(bytes[offset + index]) }
let payload = offset + lengthPrefixSize
guard length > 0, length <= bytes.count - payload else { return [] }
let end = payload + length
if length < 2 { ranges.append(offset..<end) }
offset = end
}
return offset == bytes.count ? ranges : []
}
}
147 changes: 147 additions & 0 deletions Tests/AetherEngineTests/ShortHEVCHeaderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import Foundation
import Testing
import AetherLibavcodec
import AetherLibavutil

@testable import AetherEngine

@Suite("Truncated HEVC header at a transport-stream tail")
struct ShortHEVCHeaderTests {
private func unit(_ bytes: [UInt8], width: Int = 4) -> [UInt8] {
(0..<width).reversed().map { UInt8((bytes.count >> ($0 * 8)) & 0xff) } + bytes
}
private func ranges(_ bytes: [UInt8], width: Int = 4, confirmed: Bool = true) -> [Range<Int>] {
bytes.withUnsafeBytes {
NALUnitChain.shortHEVCHeaderRanges($0, lengthPrefixSize: width, framingConfirmed: confirmed)
}
}

@Test("A complete length chain can still contain a truncated EOS header")
func damagedEOSIsRemovedWithoutLosingThePictureOrFollowingRPU() {
let picture = unit([0x26, 0x01, 0x80])
let eos = unit([0x48])
let rpu = unit([0x7c, 0x01, 0x80])
let sample = picture + eos + rpu
#expect(
sample.withUnsafeBytes { NALUnitChain.completeRunLength($0, lengthPrefixSize: 4, framingConfirmed: true) }
== nil)
#expect(ranges(sample) == [picture.count..<(picture.count + eos.count)])
let removed = ranges(sample)
let cleaned = sample.enumerated().filter { entry in
!removed.contains { $0.contains(entry.offset) }
}.map(\.element)
#expect(cleaned == picture + rpu)
}

@Test("Valid HEVC delimiters and pictures remain byte-for-byte unchanged")
func validUnitsSurvive() {
#expect(ranges(unit([0x26, 0x01, 0x80]) + unit([0x48, 0x01, 0x80])) == [])
}

@Test("Framing and bounds fail closed, and all prefix widths are supported")
func framingBoundsAndWidths() {
#expect(ranges([0, 0, 0, 1, 0x48], confirmed: false) == [])
#expect(ranges(unit([0x48]) + [0, 0, 2]) == [])
for width in 1...4 { #expect(ranges(unit([0x48], width: width), width: width) == [0..<(width + 1)]) }
}
private final class InitBytes: @unchecked Sendable {
private let lock = NSLock()
private var bytes = Data()
func set(_ data: Data) { lock.withLock { bytes = data } }
func get() -> Data { lock.withLock { bytes } }
}

@Test("The muxer compacts a shared packet and writes the correct picture/RPU size")
func muxerCompactsWithoutChangingTheSharedOriginal() throws {
try muxerRoundTrip(dropsFirstSample: false)
}

@Test("A packet containing only short headers is dropped before the next valid sample")
func muxerDropsAnAllShortSample() throws {
try muxerRoundTrip(dropsFirstSample: true)
}

private func muxerRoundTrip(dropsFirstSample: Bool) throws {
let fixture = try #require(Data(base64Encoded: Self.hevcFixture, options: .ignoreUnknownCharacters))
let demuxer = Demuxer()
defer { demuxer.close() }
try demuxer.open(reader: DataIOReader(data: fixture), formatHint: "mp4")
let stream = try #require(demuxer.stream(at: demuxer.videoStreamIndex))
#expect(stream.pointee.codecpar.pointee.codec_id == AV_CODEC_ID_HEVC)
var sourcePacket: UnsafeMutablePointer<AVPacket>? = try #require(try demuxer.readPacket())
defer { trackedPacketFree(&sourcePacket) }
let picture = Data(bytes: sourcePacket!.pointee.data!, count: Int(sourcePacket!.pointee.size))
let rpu = Data(unit([0x7c, 0x01, 0x80]))
let expected = picture + rpu
let damaged = dropsFirstSample ? Data(unit([0x48]) + unit([0x4a])) : picture + Data(unit([0x48])) + rpu
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("short-hevc-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let initial = InitBytes()
let muxer = try MP4SegmentMuxer(initialSegmentIndex: 0, sessionDir: directory,
video: .init(codecpar: UnsafePointer(stream.pointee.codecpar), timeBase: stream.pointee.time_base,
codecTagOverride: nil, nalFramingLatch: NALFramingLatch(confirmed: true)),
audio: nil, onInitCaptured: { initial.set($0) })

var packet: UnsafeMutablePointer<AVPacket>? = try #require(trackedPacketAlloc())
var shared: UnsafeMutablePointer<AVPacket>? = try #require(trackedPacketAlloc())
defer { trackedPacketFree(&packet); trackedPacketFree(&shared) }
#expect(av_new_packet(packet!, Int32(damaged.count)) >= 0)
damaged.withUnsafeBytes { _ = memcpy(packet!.pointee.data!, $0.baseAddress!, $0.count) }
packet!.pointee.pts = 0; packet!.pointee.dts = 0; packet!.pointee.duration = 512
packet!.pointee.flags = AV_PKT_FLAG_KEY
packet!.pointee.stream_index = muxer.videoOutputStreamIndex
#expect(av_packet_ref(shared!, packet!) >= 0)
let outcome = muxer.writePacket(packet!)
#expect(outcome.rc >= 0)
#expect(Data(bytes: shared!.pointee.data!, count: Int(shared!.pointee.size)) == damaged)
if dropsFirstSample {
#expect(outcome.written.pts == Int64.min && packet!.pointee.size == 0)
#expect(muxer.writtenVideoEndPts == Int64.min)
#expect(av_new_packet(packet!, Int32(expected.count)) >= 0)
expected.withUnsafeBytes { _ = memcpy(packet!.pointee.data!, $0.baseAddress!, $0.count) }
packet!.pointee.pts = 512; packet!.pointee.dts = 512; packet!.pointee.duration = 512
packet!.pointee.flags = AV_PKT_FLAG_KEY
packet!.pointee.stream_index = muxer.videoOutputStreamIndex
#expect(muxer.writePacket(packet!).rc >= 0)
} else {
#expect(outcome.written.pts == 0 && outcome.written.dts == 0)
}
guard case .completed(let path, let count) = muxer.cutFragmentForNextSegment(1) else {
Issue.record("The valid sample did not produce a complete fragment")
return
}
let fragment = try Data(contentsOf: path)
#expect(fragment.count == count)
let result = Demuxer()
defer { result.close() }
try result.open(reader: DataIOReader(data: initial.get() + fragment), formatHint: "mp4")
var clean: UnsafeMutablePointer<AVPacket>? = try #require(try result.readPacket())
defer { trackedPacketFree(&clean) }
#expect(clean!.pointee.size == expected.count)
#expect(Data(bytes: clean!.pointee.data!, count: Int(clean!.pointee.size)) == expected)
if let unexpected = try result.readPacket() {
var owned: UnsafeMutablePointer<AVPacket>? = unexpected
trackedPacketFree(&owned)
Issue.record("The dropped header-only sample survived in the fragment")
}
}

// Own synthetic two-frame 64x64 black HEVC MP4; x265 info=0, no private sample.
private static let hevcFixture = """
AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAA3ttb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAPoAAAAVAABAAABAAAAAAAA
AAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAACpnRy
YWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAVAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAA
AAAAAAAAAEAAAAAAQAAAAEAAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAAFQAAAAAAAEAAAAAAh5tZGlhAAAAIG1kaGQAAAAA
AAAAAAAAAAAAADAAAAAEAFXEAAAAAAAtaGRscgAAAAAAAAAAdmlkZQAAAAAAAAAAAAAAAFZpZGVvSGFuZGxlcgAAAAHJbWluZgAA
ABR2bWhkAAAAAQAAAAAAAAAAAAAAJGRpbmYAAAAcZHJlZgAAAAAAAAABAAAADHVybCAAAAABAAABiXN0YmwAAAEJc3RzZAAAAAAA
AAABAAAA+Wh2YzEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAQABAAEgAAABIAAAAAAAAAAEUTGF2YzYzLjEuMTAyIGxpYngyNjUA
AAAAAAAAAAAAAAAY//8AAAB1aHZjQwEBYAAAAJAAAAAAAB7wAPz9+PgAAA8DoAABABhAAQwB//8BYAAAAwCQAAADAAADAB6VmAmh
AAEAKEIBAQFgAAADAJAAAAMAAAMAHqAggQWWVmkkyvAWgIAAAAMAgAAADASiAAEAB0QBwXK0IkAAAAAKZmllbAEAAAAAEHBhc3AA
AAABAAAAAQAAABRidHJ0AAAAAAAADAAAAAAAAAAAGHN0dHMAAAAAAAAAAQAAAAIAAAIAAAAAFHN0c3MAAAAAAAAAAQAAAAEAAAAc
c3RzYwAAAAAAAAABAAAAAQAAAAIAAAABAAAAHHN0c3oAAAAAAAAAAAAAAAIAAAASAAAADgAAABRzdGNvAAAAAAAAAAEAAAOnAAAA
YXVkdGEAAABZbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAbWRpcmFwcGwAAAAAAAAAAAAAAAAsaWxzdAAAACSpdG9vAAAAHGRhdGEA
AAABAAAAAExhdmY2My4xLjEwMgAAAAhmcmVlAAAAKG1kYXQAAAAOKAGvHYD3AbL//SMvszwAAAAKAgHQCX4Qxjv6hA==
"""

}
5 changes: 5 additions & 0 deletions docs/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,11 @@ Non-DV HEVC derives its primary `CODECS` string from the source `hvcC` profile_t

Whatever the profile, the `hvcC` shipped in `init.mp4` must carry the parameter sets out of band. Sources authored with in-band VPS/SPS/PPS only (`hev1`, `numOfArrays = 0`, what `MP4Box ...:xps_inband` and the common Dolby Vision MP4 recipes produce) are normalized at load by scanning the head of the stream for the parameter sets and rebuilding the record; a record that arrives with extra non-parameter-set arrays (libx265's user-data SEI) is stripped down to VPS/SPS/PPS instead. Shipping the source record verbatim in either case leaves AVPlayer with no usable format description: it buffers the whole forward window and never renders a frame. H.264 gets the same rebuild (#938): an `avcC` with zero SPS or zero PPS (mkvmerge writes the 7-byte `01 64 00 20 ff e0 00` for such a track) is rebuilt from the first in-band SPS and PPS, by movenc so the High-profile extension comes out right, with the source's NAL length size kept. Shipped verbatim it fails differently from HEVC and more quietly: the item reaches `readyToPlay`, the clock runs and audio plays, and no frame is ever ready for display.

A chain can also walk exactly while a unit contains only one byte of HEVC's two-byte header
(for example a truncated EOS). In confirmed length-prefixed HEVC samples the muxer removes those
units, retaining valid pictures and any following Dolby Vision RPU. Shared packets are made writable
before compaction; a sample with no complete header left is not written (#960).

### Dolby Vision signaling

For DV streams the demuxer surfaces the source's `AVDOVIDecoderConfigurationRecord`, and the route depends on the profile's base-layer compatibility:
Expand Down