diff --git a/clients/apple/README.md b/clients/apple/README.md index 27da0c40..76193017 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -10,7 +10,8 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat ## Features -- **Hardware decode** — VideoToolbox HEVC, with a low-latency **stage-2 presenter** +- **Hardware decode** — VideoToolbox H.264/HEVC (plus **AV1** on devices with an AV1 hardware + decoder — M3-class Macs, A17 Pro-class iPhones), with a low-latency **stage-2 presenter** (`VTDecompressionSession` → `CAMetalLayer`, presented off a `CADisplayLink`, ~11 ms p50) as the default and an `AVSampleBufferDisplayLayer` fallback. - **HDR & 4:4:4** — PQ passthrough with a correct reference-white anchor, mid-session SDR↔HDR diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index f2f60cc8..c2b55dfb 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -194,11 +194,13 @@ final class SessionModel: ObservableObject { if want444, canDecode444 { videoCaps |= PunktfunkConnection.videoCap444 } - // This client's VideoToolbox path decodes H.264 and HEVC (AV1 depacketization isn't - // wired — AnnexB.swift is NAL-only — so it must never be advertised here). The host - // resolves the emitted codec from these + the soft `preferredCodec`; `resolvedCodec` - // reflects what it chose. - let videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC + // This client's VideoToolbox path decodes H.264 and HEVC everywhere, and AV1 when + // this device has an AV1 hardware decoder (M3-class Macs, A17 Pro-class iPhones — + // VideoToolbox has no software AV1 decoder, so advertising it elsewhere would invite + // a stream that can't decode; see AV1.swift). The host resolves the emitted codec + // from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose. + var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC + if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 } let result = Result { try PunktfunkConnection( host: host.address, port: host.port, width: width, height: height, refreshHz: hz, diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index 81314484..b6618c89 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -38,13 +38,21 @@ enum SettingsOptions { HUDPlacement.allCases.map { ($0.label, $0.rawValue) } /// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from. - /// No AV1: this client's VideoToolbox path decodes H.264/HEVC only (AnnexB.swift is NAL-only), - /// so it never advertises AV1 — offering it here would be a dead setting. - static let codecs: [(label: String, tag: String)] = [ - ("Automatic", "auto"), - ("HEVC (H.265)", "hevc"), - ("H.264 (AVC)", "h264"), - ] + /// AV1 appears only on devices with an AV1 hardware decoder (the same + /// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a + /// dead setting the host could never honor. Ordered by the host's resolve precedence + /// (HEVC > AV1 > H.264). + static let codecs: [(label: String, tag: String)] = { + var options: [(label: String, tag: String)] = [ + ("Automatic", "auto"), + ("HEVC (H.265)", "hevc"), + ("H.264 (AVC)", "h264"), + ] + if AV1.hardwareDecodeSupported { + options.insert(("AV1", "av1"), at: 2) + } + return options + }() // MARK: - Bitrate diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 1a7297d4..7f57f675 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -269,7 +269,8 @@ public final class PunktfunkConnection { /// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1. Build the decoder from THIS. The /// resolved value honors the client's `preferredCodec` when the host could emit it. public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC - /// The resolved codec as an `AnnexB.VideoCodec` (H.264 vs HEVC) — drives the NAL parsing. + /// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing + /// (Annex-B NAL parsing vs the AV1 OBU repack). public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) } /// Connect and start a session at the requested mode (the host creates a native virtual diff --git a/clients/apple/Sources/PunktfunkKit/Video/AV1.swift b/clients/apple/Sources/PunktfunkKit/Video/AV1.swift new file mode 100644 index 00000000..cb0cc9c7 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Video/AV1.swift @@ -0,0 +1,561 @@ +// AV1 (low-overhead OBU bitstream) → CoreMedia plumbing — the AV1 sibling of AnnexB.swift. +// +// The punktfunk host emits AV1 access units as low-overhead temporal units (the raw encoder +// output every other client feeds ffmpeg): a temporal-delimiter OBU, then — on every keyframe, +// per the same in-band-config policy as the NAL codecs — a sequence-header OBU, then the frame +// OBUs. VideoToolbox instead wants the ISOBMFF 'av01' flavor: a CMVideoFormatDescription +// carrying an `av1C` configuration record (built from the sequence header), and sample buffers +// holding the temporal unit with the temporal delimiter stripped and every OBU size-fielded. +// This file converts between the two. +// +// HOT PATH: like AnnexB, both pumps run `formatDescription(fromKeyframe:)` + +// `sampleBuffer(au:format:)` once per AU, so everything is built on `forEachOBU` — a zero-copy +// scan over the AU's bytes (ranges, not materialized Datas). A delta AU (no sequence header) +// costs a few OBU-header reads; the sample repack leaves exactly one copy (source → block +// buffer), mirroring AnnexB.sampleBuffer. +// +// The full sequence-header parse (AV1 spec 5.5.1) runs only when a keyframe actually carries +// one — it exists to fill the `av1C` record fields (profile/level/tier/depth/chroma) and the +// colorimetry extensions (so VideoDecoder.isHDRFormat and the presenter's color handling work +// identically across codecs). The host currently gates 10-bit and 4:4:4 to HEVC, so an AV1 +// stream is 8-bit 4:2:0 today; the parser still reads depth/chroma/color faithfully so nothing +// here needs touching when that gate lifts. + +import CoreMedia +import Foundation +import VideoToolbox + +public enum AV1 { + /// True when this device can hardware-decode AV1 (M3-class Macs, A17 Pro-class iPhones, + /// current iPads; false on every Apple TV to date). VideoToolbox has no software AV1 + /// decoder, so this is the advertisement gate: a client must never invite a stream it + /// can't decode in real time. + public static let hardwareDecodeSupported: Bool = + VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1) + + // MARK: - OBU walking + + /// OBU types (AV1 spec 6.2.2) — only the ones this file dispatches on. + enum OBUType { + static let sequenceHeader: UInt8 = 1 + static let temporalDelimiter: UInt8 = 2 + static let padding: UInt8 = 15 + } + + /// Walk the OBUs of a low-overhead temporal unit without copying: `body` receives the buffer + /// base, each OBU's header range (header byte + optional extension byte + size field, i.e. + /// everything before the payload), payload range, and type — and returns false to stop early. + /// The walk ends at the first malformed OBU (forbidden bit set, truncated header, or a size + /// field overrunning the buffer): a torn AU decodes as garbage anyway and the pumps' keyframe + /// recovery re-anchors, so bailing beats guessing at boundaries. An OBU with + /// `obu_has_size_field == 0` extends to the end of the buffer (legal only for the last one). + /// The base pointer is only valid inside `body`. + static func forEachOBU( + in data: Data, + _ body: ( + _ base: UnsafePointer, _ header: Range, _ payload: Range, + _ type: UInt8 + ) -> Bool + ) { + data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return } + let count = raw.count + var i = 0 + while i < count { + let start = i + let h = base[i] + guard h & 0x80 == 0 else { return } // obu_forbidden_bit — not an OBU stream + let type = (h >> 3) & 0x0F + let hasExtension = h & 0x04 != 0 + let hasSize = h & 0x02 != 0 + i += 1 + if hasExtension { + guard i < count else { return } + i += 1 + } + let payloadLen: Int + if hasSize { + guard let (size, sizeLen) = leb128(base: base, at: i, count: count) + else { return } + i += sizeLen + payloadLen = size + } else { + payloadLen = count - i // no size field: extends to the end (must be last) + } + guard i + payloadLen <= count else { return } + if !body(base, start.., at: Int, count: Int + ) -> (Int, Int)? { + var value: UInt64 = 0 + for i in 0..<8 { + guard at + i < count else { return nil } + let byte = base[at + i] + value |= UInt64(byte & 0x7F) << (7 * i) + if byte & 0x80 == 0 { + guard value <= UInt64(UInt32.max) else { return nil } + return (Int(value), i + 1) + } + } + return nil + } + + /// leb128-encoded byte length of `value`. + private static func leb128Length(_ value: Int) -> Int { + var v = UInt32(value) + var n = 1 + while v >= 0x80 { + v >>= 7 + n += 1 + } + return n + } + + /// Encode `value` as leb128 into `dst`; returns the byte count written. + private static func putLeb128(_ value: Int, into dst: UnsafeMutableRawPointer) -> Int { + var v = UInt32(value) + var n = 0 + repeat { + var byte = UInt8(v & 0x7F) + v >>= 7 + if v != 0 { byte |= 0x80 } + dst.storeBytes(of: byte, toByteOffset: n, as: UInt8.self) + n += 1 + } while v != 0 + return n + } + + // MARK: - Sequence header + + /// The sequence-header fields the `av1C` record and the colorimetry extensions need + /// (AV1 spec 5.5; color codes are ITU-T H.273, shared with the HEVC VUI). + struct SequenceHeader { + var profile: UInt8 = 0 + var levelIdx0: UInt8 = 0 + var tier0: UInt8 = 0 + var highBitdepth = false + var twelveBit = false + var monochrome = false + var subsamplingX = true + var subsamplingY = true + var chromaSamplePosition: UInt8 = 0 + /// H.273 codes; 2 = unspecified (the spec default when no color description is coded). + var colorPrimaries: UInt8 = 2 + var transferCharacteristics: UInt8 = 2 + var matrixCoefficients: UInt8 = 2 + var fullRange = false + var maxWidth = 0 + var maxHeight = 0 + } + + /// MSB-first bit reader over the sequence-header payload. Every read is bounds-checked and + /// returns nil on overrun — the parser guard-lets each field so a truncated header yields + /// nil rather than garbage. + private struct BitReader { + private let bytes: UnsafePointer + private let bitCount: Int + private var pos = 0 + + init(bytes: UnsafePointer, count: Int) { + self.bytes = bytes + self.bitCount = count * 8 + } + + mutating func f(_ n: Int) -> UInt32? { + guard n <= 32, pos + n <= bitCount else { return nil } + var v: UInt32 = 0 + for _ in 0..> 3] >> (7 - UInt8(pos & 7))) & 1 + v = (v << 1) | UInt32(bit) + pos += 1 + } + return v + } + + mutating func flag() -> Bool? { f(1).map { $0 == 1 } } + + /// uvlc() (spec 4.10.3) — only `num_ticks_per_picture_minus_1` uses it here. + mutating func uvlc() -> UInt32? { + var leadingZeros = 0 + while true { + guard let b = f(1) else { return nil } + if b == 1 { break } + leadingZeros += 1 + if leadingZeros >= 32 { return nil } + } + if leadingZeros == 0 { return 0 } + guard let v = f(leadingZeros) else { return nil } + return v + (1 << leadingZeros) - 1 + } + } + + /// Parse a sequence-header OBU payload (spec 5.5.1 — the full walk down to color_config, + /// which is what `av1C` + the colorimetry extensions are built from). Returns nil on any + /// truncation or spec violation. + static func parseSequenceHeader(_ payload: Data) -> SequenceHeader? { + payload.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> SequenceHeader? in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return nil } + var r = BitReader(bytes: base, count: raw.count) + var sh = SequenceHeader() + + guard let profile = r.f(3), profile <= 2 else { return nil } + sh.profile = UInt8(profile) + guard r.flag() != nil else { return nil } // still_picture + guard let reduced = r.flag() else { return nil } + + var decoderModelInfoPresent = false + var bufferDelayLengthMinus1 = 0 + if reduced { + guard let level = r.f(5) else { return nil } + sh.levelIdx0 = UInt8(level) + sh.tier0 = 0 + } else { + guard let timingInfoPresent = r.flag() else { return nil } + if timingInfoPresent { + guard r.f(32) != nil, r.f(32) != nil, // num_units_in_display_tick, time_scale + let equalPictureInterval = r.flag() + else { return nil } + if equalPictureInterval, r.uvlc() == nil { return nil } + guard let dmip = r.flag() else { return nil } + decoderModelInfoPresent = dmip + if decoderModelInfoPresent { + guard let bdl = r.f(5), r.f(32) != nil, r.f(5) != nil, r.f(5) != nil + else { return nil } + bufferDelayLengthMinus1 = Int(bdl) + } + } + guard let initialDisplayDelayPresent = r.flag(), + let opCountMinus1 = r.f(5) + else { return nil } + for i in 0...Int(opCountMinus1) { + guard r.f(12) != nil, let level = r.f(5) else { return nil } + var tier: UInt32 = 0 + if level > 7 { + guard let t = r.f(1) else { return nil } + tier = t + } + if i == 0 { + sh.levelIdx0 = UInt8(level) + sh.tier0 = UInt8(tier) + } + if decoderModelInfoPresent { + guard let present = r.flag() else { return nil } + if present { + let n = bufferDelayLengthMinus1 + 1 + guard r.f(n) != nil, r.f(n) != nil, r.f(1) != nil else { return nil } + } + } + if initialDisplayDelayPresent { + guard let present = r.flag() else { return nil } + if present, r.f(4) == nil { return nil } + } + } + } + + guard let widthBitsMinus1 = r.f(4), let heightBitsMinus1 = r.f(4), + let maxWidthMinus1 = r.f(Int(widthBitsMinus1) + 1), + let maxHeightMinus1 = r.f(Int(heightBitsMinus1) + 1) + else { return nil } + sh.maxWidth = Int(maxWidthMinus1) + 1 + sh.maxHeight = Int(maxHeightMinus1) + 1 + + if !reduced { + guard let frameIdNumbersPresent = r.flag() else { return nil } + if frameIdNumbersPresent { + guard r.f(4) != nil, r.f(3) != nil else { return nil } + } + } + // use_128x128_superblock, enable_filter_intra, enable_intra_edge_filter + guard r.f(3) != nil else { return nil } + if !reduced { + // enable_interintra_compound … enable_dual_filter + guard r.f(4) != nil, let enableOrderHint = r.flag() else { return nil } + if enableOrderHint { + guard r.f(2) != nil else { return nil } // jnt_comp, ref_frame_mvs + } + guard let chooseScreenContentTools = r.flag() else { return nil } + let forceScreenContentTools: UInt32 + if chooseScreenContentTools { + forceScreenContentTools = 2 // SELECT_SCREEN_CONTENT_TOOLS + } else { + guard let v = r.f(1) else { return nil } + forceScreenContentTools = v + } + if forceScreenContentTools > 0 { + guard let chooseIntegerMv = r.flag() else { return nil } + if !chooseIntegerMv, r.f(1) == nil { return nil } + } + if enableOrderHint, r.f(3) == nil { return nil } // order_hint_bits_minus_1 + } + // enable_superres, enable_cdef, enable_restoration + guard r.f(3) != nil else { return nil } + + // color_config() (spec 5.5.2) + guard let highBitdepth = r.flag() else { return nil } + sh.highBitdepth = highBitdepth + if sh.profile == 2, highBitdepth { + guard let twelveBit = r.flag() else { return nil } + sh.twelveBit = twelveBit + } + if sh.profile == 1 { + sh.monochrome = false + } else { + guard let mono = r.flag() else { return nil } + sh.monochrome = mono + } + guard let colorDescriptionPresent = r.flag() else { return nil } + if colorDescriptionPresent { + guard let cp = r.f(8), let tc = r.f(8), let mc = r.f(8) else { return nil } + sh.colorPrimaries = UInt8(cp) + sh.transferCharacteristics = UInt8(tc) + sh.matrixCoefficients = UInt8(mc) + } + if sh.monochrome { + guard let fullRange = r.flag() else { return nil } + sh.fullRange = fullRange + sh.subsamplingX = true + sh.subsamplingY = true + sh.chromaSamplePosition = 0 + return sh + } + if sh.colorPrimaries == 1, sh.transferCharacteristics == 13, + sh.matrixCoefficients == 0 { + // BT.709 + sRGB + identity forces full-range 4:4:4. + sh.fullRange = true + sh.subsamplingX = false + sh.subsamplingY = false + return sh + } + guard let fullRange = r.flag() else { return nil } + sh.fullRange = fullRange + switch sh.profile { + case 0: + sh.subsamplingX = true + sh.subsamplingY = true + case 1: + sh.subsamplingX = false + sh.subsamplingY = false + default: // profile 2 + if sh.highBitdepth, sh.twelveBit { + guard let ssx = r.flag() else { return nil } + sh.subsamplingX = ssx + if ssx { + guard let ssy = r.flag() else { return nil } + sh.subsamplingY = ssy + } else { + sh.subsamplingY = false + } + } else { + sh.subsamplingX = true + sh.subsamplingY = false + } + } + if sh.subsamplingX, sh.subsamplingY { + guard let csp = r.f(2) else { return nil } + sh.chromaSamplePosition = UInt8(csp) + } + return sh + } + } + + // MARK: - Format description + + /// Build a format description from a keyframe AU's in-band sequence header — the AV1 + /// equivalent of `AnnexB.formatDescription(fromIDR:)`. Returns nil when the AU carries no + /// sequence-header OBU (a delta frame): the pumps latch the previous description exactly as + /// they do for the NAL codecs. The description carries the `av1C` record (with the sequence + /// header as its configOBUs) plus colorimetry extensions mapped from color_config, so + /// `VideoDecoder.isHDRFormat` and the presenter treat AV1 like any other stream. + public static func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? { + // The sequence-header OBU, re-emitted with a size field (encoders size-field everything + // in practice; the rewrap also covers a last-OBU-without-size corner). + var seqHeaderOBU: Data? + var seqHeaderPayload: Data? + forEachOBU(in: au) { base, header, payload, type in + guard type == OBUType.sequenceHeader else { return true } + var obu = Data(capacity: 2 + leb128Length(payload.count) + payload.count) + obu.append(base[header.lowerBound] | 0x02) // has_size_field set + if base[header.lowerBound] & 0x04 != 0 { // extension byte rides along + obu.append(base[header.lowerBound + 1]) + } + var lenBuf = [UInt8](repeating: 0, count: 8) + let lenLen = lenBuf.withUnsafeMutableBytes { + putLeb128(payload.count, into: $0.baseAddress!) + } + obu.append(contentsOf: lenBuf[0.. 0, sh.maxHeight > 0 + else { return nil } + + // AV1CodecConfigurationRecord (AV1-ISOBMFF §2.3): 4 fixed bytes + configOBUs. + var av1C = Data(capacity: 4 + seqHeaderOBU.count) + av1C.append(0x81) // marker=1, version=1 + av1C.append((sh.profile << 5) | sh.levelIdx0) + av1C.append( + (sh.tier0 << 7) + | ((sh.highBitdepth ? 1 : 0) << 6) + | ((sh.twelveBit ? 1 : 0) << 5) + | ((sh.monochrome ? 1 : 0) << 4) + | ((sh.subsamplingX ? 1 : 0) << 3) + | ((sh.subsamplingY ? 1 : 0) << 2) + | sh.chromaSamplePosition) + av1C.append(0) // no initial_presentation_delay + av1C.append(seqHeaderOBU) + + // Colorimetry from color_config's H.273 codes; unspecified (2) falls back to BT.709 — + // the host's SDR default, same policy the presenter applies elsewhere. + let primaries: CFString = { + switch sh.colorPrimaries { + case 9: return kCMFormatDescriptionColorPrimaries_ITU_R_2020 + case 6: return kCMFormatDescriptionColorPrimaries_SMPTE_C + case 5: return kCMFormatDescriptionColorPrimaries_EBU_3213 + default: return kCMFormatDescriptionColorPrimaries_ITU_R_709_2 + } + }() + let transfer: CFString = { + switch sh.transferCharacteristics { + case 16: return kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ + case 18: return kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG + case 13: return kCMFormatDescriptionTransferFunction_sRGB + case 8: return kCMFormatDescriptionTransferFunction_Linear + default: return kCMFormatDescriptionTransferFunction_ITU_R_709_2 + } + }() + let matrix: CFString = { + switch sh.matrixCoefficients { + case 9, 10: return kCMFormatDescriptionYCbCrMatrix_ITU_R_2020 + case 5, 6: return kCMFormatDescriptionYCbCrMatrix_ITU_R_601_4 + default: return kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2 + } + }() + let extensions: [CFString: Any] = [ + kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms: ["av1C": av1C], + kCMFormatDescriptionExtension_ColorPrimaries: primaries, + kCMFormatDescriptionExtension_TransferFunction: transfer, + kCMFormatDescriptionExtension_YCbCrMatrix: matrix, + kCMFormatDescriptionExtension_FullRangeVideo: sh.fullRange, + ] + var format: CMVideoFormatDescription? + let status = CMVideoFormatDescriptionCreate( + allocator: kCFAllocatorDefault, + codecType: kCMVideoCodecType_AV1, + width: Int32(sh.maxWidth), height: Int32(sh.maxHeight), + extensions: extensions as CFDictionary, + formatDescriptionOut: &format) + return status == noErr ? format : nil + } + + // MARK: - Sample buffers + + /// Wrap one temporal unit as a decode-ready CMSampleBuffer in the ISOBMFF 'av01' sample + /// format: the temporal-delimiter (and padding) OBUs are dropped, every remaining OBU is + /// re-emitted with a size field, and — mirroring AnnexB.sampleBuffer — the result is packed + /// straight into the CMBlockBuffer's allocation (sized by a first cheap scan). The sequence + /// header stays in-band (spec-legal: it's bit-identical to the one in `av1C`, which is + /// rebuilt from the same keyframe), preserving the host's self-contained-keyframe policy. + public static func sampleBuffer( + au: AccessUnit, format: CMVideoFormatDescription + ) -> CMSampleBuffer? { + // Pass 1: byte scan only — total repacked size of the kept OBUs. + var total = 0 + forEachOBU(in: au.data) { base, header, payload, type in + if type == OBUType.temporalDelimiter || type == OBUType.padding { return true } + let headerLen = base[header.lowerBound] & 0x04 != 0 ? 2 : 1 + total += headerLen + leb128Length(payload.count) + payload.count + return true + } + // Nothing decodable (a delimiter-only AU — our host never sends one): drop it rather + // than hand the decoder an empty sample. + guard total > 0 else { return nil } + + var blockBuffer: CMBlockBuffer? + guard CMBlockBufferCreateWithMemoryBlock( + allocator: kCFAllocatorDefault, memoryBlock: nil, + blockLength: total, blockAllocator: kCFAllocatorDefault, + customBlockSource: nil, offsetToData: 0, dataLength: total, + flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &blockBuffer) == noErr, + let block = blockBuffer + else { return nil } + var dstLen = 0 + var dstPtr: UnsafeMutablePointer? + guard CMBlockBufferGetDataPointer( + block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &dstLen, + dataPointerOut: &dstPtr) == noErr, + dstLen == total, let dstPtr + else { return nil } + // Pass 2: the single copy — header (+extension) byte, size field, payload per OBU. + let dst = UnsafeMutableRawPointer(dstPtr) + var off = 0 + forEachOBU(in: au.data) { base, header, payload, type in + if type == OBUType.temporalDelimiter || type == OBUType.padding { return true } + dst.storeBytes( + of: base[header.lowerBound] | 0x02, toByteOffset: off, as: UInt8.self) + off += 1 + if base[header.lowerBound] & 0x04 != 0 { + dst.storeBytes( + of: base[header.lowerBound + 1], toByteOffset: off, as: UInt8.self) + off += 1 + } + off += putLeb128(payload.count, into: dst.advanced(by: off)) + dst.advanced(by: off) + .copyMemory(from: base + payload.lowerBound, byteCount: payload.count) + off += payload.count + return true + } + + var timing = CMSampleTimingInfo( + duration: .invalid, + presentationTimeStamp: CMTime(value: Int64(au.ptsNs), timescale: 1_000_000_000), + decodeTimeStamp: .invalid) + var sampleSize = total + var sample: CMSampleBuffer? + guard CMSampleBufferCreate( + allocator: kCFAllocatorDefault, dataBuffer: block, dataReady: true, + makeDataReadyCallback: nil, refcon: nil, formatDescription: format, + sampleCount: 1, sampleTimingEntryCount: 1, sampleTimingArray: &timing, + sampleSizeEntryCount: 1, sampleSizeArray: &sampleSize, + sampleBufferOut: &sample) == noErr + else { return nil } + // Low-latency display: render on arrival, don't wait for a clock. + if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample!, createIfNecessary: true) { + let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self) + CFDictionarySetValue( + dict, + Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(), + Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()) + } + return sample + } +} + +extension VideoCodec { + /// Codec-dispatching format-description refresh: the AV1 path keys on an in-band sequence + /// header, the NAL codecs on in-band parameter sets — one call site in each pump. + public func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? { + self == .av1 + ? AV1.formatDescription(fromKeyframe: au) + : AnnexB.formatDescription(fromIDR: au, codec: self) + } + + /// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`). + public func sampleBuffer( + au: AccessUnit, format: CMVideoFormatDescription + ) -> CMSampleBuffer? { + self == .av1 + ? AV1.sampleBuffer(au: au, format: format) + : AnnexB.sampleBuffer(au: au, format: format, codec: self) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift b/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift index baf181bb..303e7a8d 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift @@ -6,8 +6,10 @@ // buffers whose NALs are 4-byte-length-prefixed. This file converts between the two, for // the codec the host resolved in the Welcome (`connection.videoCodec`) — HEVC and H.264 // differ only in NAL-header layout and which parameter sets exist (HEVC adds a VPS). AV1 -// is not an Annex-B/NAL codec and isn't handled here — this client never advertises it in -// the Hello, so a host never emits it at us. +// is not an Annex-B/NAL codec and isn't handled here — its OBU flavor of the same plumbing +// lives in AV1.swift, and the pumps reach both through `VideoCodec`'s dispatching +// `formatDescription(fromKeyframe:)` / `sampleBuffer(au:format:)`, so nothing below is ever +// called with `.av1`. // // HOT PATH: both pumps run `formatDescription(fromIDR:codec:)` + `sampleBuffer(au:format:codec:)` // once per AU, so the conversion is built on `forEachNAL` — a zero-copy scan over the AU's bytes @@ -23,10 +25,15 @@ import Foundation public enum VideoCodec: Equatable { case h264 case hevc + case av1 /// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC). public init(wire: UInt8) { - self = wire == 0x01 ? .h264 : .hevc // 0x01 = PUNKTFUNK_CODEC_H264 + switch wire { + case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264 + case 0x04: self = .av1 // PUNKTFUNK_CODEC_AV1 + default: self = .hevc // PUNKTFUNK_CODEC_HEVC — the default / older-host codec + } } /// NAL unit type from a NAL's first byte. HEVC: bits 1..6; H.264: bits 0..4. @@ -140,6 +147,8 @@ public enum AnnexB { sets = [vps, sps, pps] case .h264: sets = [sps, pps] + case .av1: + return nil // OBU stream, no parameter-set NALs — handled in AV1.swift, never here } var format: CMVideoFormatDescription? @@ -175,6 +184,8 @@ public enum AnnexB { parameterSetSizes: sizes, nalUnitHeaderLength: 4, formatDescriptionOut: &format) + case .av1: + break // unreachable — the .av1 arm above already returned } } return status == noErr ? format : nil diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index f2ae7df2..c878d90e 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -277,7 +277,7 @@ public final class Stage2Pipeline { } guard let au = try connection.nextAU(timeoutMs: 100) else { continue } onFrame?(au) - if let f = AnnexB.formatDescription(fromIDR: au.data, codec: connection.videoCodec) { + if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) { format = f // refreshed on every IDR (mode changes included) awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete } diff --git a/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift index 9e04f48d..5bab2cc2 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift @@ -71,7 +71,7 @@ final class StreamPump { guard let au = try connection.nextAU(timeoutMs: 100) else { continue } onFrame?(au) - let idrFormat = AnnexB.formatDescription(fromIDR: au.data, codec: connection.videoCodec) + let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data) if let f = idrFormat { format = f // refreshed on every IDR (mode changes included) if awaitingIDR { @@ -95,7 +95,7 @@ final class StreamPump { } wasFailed = failed guard let f = format, - let sample = AnnexB.sampleBuffer(au: au, format: f, codec: connection.videoCodec), + let sample = connection.videoCodec.sampleBuffer(au: au, format: f), !token.isStopped // don't enqueue a stale frame after a restart else { continue } layer.enqueue(sample) diff --git a/clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift b/clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift index 853e83ea..17b65c7b 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift @@ -1,4 +1,5 @@ -// Stage-2 presenter, decode half: explicit VideoToolbox decode of the host's HEVC AUs. +// Stage-2 presenter, decode half: explicit VideoToolbox decode of the host's AUs (H.264 / +// HEVC / AV1 — whatever the Welcome resolved). // // Stage-1 hands compressed samples to AVSampleBufferDisplayLayer, which decodes AND presents // internally with no per-frame callback — so neither decode-completion nor present can be @@ -61,8 +62,8 @@ public final class VideoDecoder: @unchecked Sendable { /// depth / HDR). Read inside `createSessionLocked` under `lock`. private var chroma444 = false - /// The negotiated codec (`connection.videoCodec`), set once at session start. Drives the AnnexB - /// NAL parsing (H.264 vs HEVC parameter sets). Read under `lock`. + /// The negotiated codec (`connection.videoCodec`), set once at session start. Drives the + /// bitstream framing (H.264/HEVC NAL parsing vs AV1 OBU repack). Read under `lock`. private var codec: VideoCodec = .hevc public init( @@ -84,8 +85,8 @@ public final class VideoDecoder: @unchecked Sendable { lock.unlock() } - /// Select the negotiated codec (H.264 vs HEVC). Call once at session start, before decoding, - /// from `connection.videoCodec`. Thread-safe. + /// Select the negotiated codec (H.264 / HEVC / AV1). Call once at session start, before + /// decoding, from `connection.videoCodec`. Thread-safe. public func setCodec(_ c: VideoCodec) { lock.lock() codec = c @@ -93,8 +94,9 @@ public final class VideoDecoder: @unchecked Sendable { } /// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The - /// caller resolves `format` from the IDR exactly as stage-1 does (`AnnexB.formatDescription`). - /// Returns false if the session couldn't be created or the frame couldn't be submitted. + /// caller resolves `format` from the keyframe exactly as stage-1 does + /// (`VideoCodec.formatDescription(fromKeyframe:)`). Returns false if the session couldn't be + /// created or the frame couldn't be submitted. @discardableResult public func decode(au: AccessUnit, format newFormat: CMVideoFormatDescription) -> Bool { lock.lock() @@ -112,7 +114,7 @@ public final class VideoDecoder: @unchecked Sendable { // invalidate the session between here and DecodeFrame. The VT output callback takes the // ring lock, not this one, so there's no re-entrancy. DecodeFrame is async — non-blocking. guard let session, - let sample = AnnexB.sampleBuffer(au: au, format: newFormat, codec: codec) + let sample = codec.sampleBuffer(au: au, format: newFormat) else { lock.unlock(); return false } var infoOut = VTDecodeInfoFlags() let status = VTDecompressionSessionDecodeFrame( @@ -199,13 +201,14 @@ public final class VideoDecoder: @unchecked Sendable { var callback = VTDecompressionOutputCallbackRecord( decompressionOutputCallback: decoderOutputCallback, decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque()) - // 4:4:4 sessions REQUIRE a hardware decoder: we only advertise 4:4:4 when the hardware probe - // passed, so a hardware-incapable mode (e.g. a resolution past the HW 4:4:4 ceiling) must fail - // HERE, synchronously, letting the pump's backstop end the session — rather than silently - // falling back to a software 4:4:4 decoder far too slow for a real-time stream. 4:2:0 keeps the - // software fallback (nil spec) as a robustness net. + // 4:4:4 and AV1 sessions REQUIRE a hardware decoder: both are only advertised when the + // hardware gate passed (the 4:4:4 probe / `AV1.hardwareDecodeSupported`), so a + // hardware-incapable mode (e.g. a resolution past a HW ceiling) must fail HERE, + // synchronously, letting the pump's backstop end the session — rather than silently + // falling back to a software decoder far too slow for a real-time stream. 4:2:0 + // H.264/HEVC keeps the software fallback (nil spec) as a robustness net. let spec: CFDictionary? = - chroma444 + chroma444 || codec == .av1 ? [kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder: true] as CFDictionary : nil var newSession: VTDecompressionSession? diff --git a/clients/apple/Tests/PunktfunkKitTests/AV1Tests.swift b/clients/apple/Tests/PunktfunkKitTests/AV1Tests.swift new file mode 100644 index 00000000..2217603d --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/AV1Tests.swift @@ -0,0 +1,306 @@ +// Real-bitstream tests for the AV1 OBU → CoreMedia plumbing (AV1.swift): the OBU walk, the +// sequence-header parse, the `av1C` format description, the sample repack, the VideoCodec +// dispatch — and, on AV1-hardware devices, a real VTDecompressionSession decode of the blob +// (the AV1 counterpart of VideoToolboxRoundTripTests; there is no VT AV1 *encoder*, so the +// bitstream is generated offline). +// +// Blobs: the first two temporal units of an SVT-AV1 clip — a keyframe TU (temporal delimiter + +// sequence header + frame) and a delta TU (temporal delimiter + frame), exactly the wire shape +// the punktfunk host emits. Generated with: +// ffmpeg -f lavfi -i testsrc2=size=320x180:rate=30 -frames:v 2 \ +// -c:v libsvtav1 -preset 12 -crf 63 -g 30 -f obu out.obu +// then split on the temporal-delimiter OBUs. 320×180 clears the hardware decoder's +// minimum-dimension floor (see Probe444Blobs). Ground truth (ffprobe + a reference parse): +// Main profile (0), level_idx 0, tier 0, 8-bit, 4:2:0, no color description (unspecified), +// studio range, max frame 320×180, chroma sample position 0. + +import CoreMedia +import VideoToolbox +import XCTest +@testable import PunktfunkKit + +/// Sendable holder for the values the (background-thread) decode callback writes. +private final class FrameBox: @unchecked Sendable { + let lock = NSLock() + var frame: ReadyFrame? + var error: OSStatus? +} + +final class AV1Tests: XCTestCase { + // MARK: - OBU walk + + func testOBUWalkKeyframe() { + var seen: [(type: UInt8, payloadCount: Int)] = [] + var lastEnd = 0 + AV1.forEachOBU(in: Data(Self.keyframeTU)) { _, header, payload, type in + XCTAssertEqual(header.lowerBound, lastEnd, "OBUs must be contiguous") + XCTAssertEqual(header.upperBound, payload.lowerBound) + lastEnd = payload.upperBound + seen.append((type, payload.count)) + return true + } + XCTAssertEqual(lastEnd, Self.keyframeTU.count, "walk must cover the whole TU") + XCTAssertEqual(seen.map(\.type), [ + AV1.OBUType.temporalDelimiter, AV1.OBUType.sequenceHeader, 6, // 6 = OBU_FRAME + ]) + XCTAssertEqual(seen[0].payloadCount, 0) + XCTAssertEqual(seen[1].payloadCount, 11) + } + + func testOBUWalkStopsEarly() { + var calls = 0 + AV1.forEachOBU(in: Data(Self.keyframeTU)) { _, _, _, _ in + calls += 1 + return false + } + XCTAssertEqual(calls, 1) + } + + func testOBUWalkRejectsGarbage() { + // 0x80 = forbidden bit set: not an OBU stream, the walk must not call the body. + AV1.forEachOBU(in: Data([0x80, 0x00, 0x01])) { _, _, _, _ in + XCTFail("garbage must not yield OBUs") + return true + } + // A size field overrunning the buffer stops the walk at the previous OBU. + var truncated = Data([0x12, 0x00]) // valid TD + truncated.append(contentsOf: [0x0A, 0x7F, 0x01]) // seq header claiming 127 bytes, 1 present + var types: [UInt8] = [] + AV1.forEachOBU(in: truncated) { _, _, _, type in + types.append(type) + return true + } + XCTAssertEqual(types, [AV1.OBUType.temporalDelimiter]) + } + + // MARK: - Sequence header + + func testSequenceHeaderParse() throws { + // The sequence-header OBU payload sits at bytes 4..<15 (TD 2 bytes, header+size 2 bytes). + let payload = Data(Self.keyframeTU[4..<15]) + let sh = try XCTUnwrap(AV1.parseSequenceHeader(payload)) + XCTAssertEqual(sh.profile, 0) // Main + XCTAssertEqual(sh.levelIdx0, 0) + XCTAssertEqual(sh.tier0, 0) + XCTAssertFalse(sh.highBitdepth) + XCTAssertFalse(sh.twelveBit) + XCTAssertFalse(sh.monochrome) + XCTAssertTrue(sh.subsamplingX) // profile 0 ⇒ 4:2:0 + XCTAssertTrue(sh.subsamplingY) + XCTAssertEqual(sh.chromaSamplePosition, 0) + XCTAssertEqual(sh.colorPrimaries, 2) // no color description ⇒ unspecified + XCTAssertEqual(sh.transferCharacteristics, 2) + XCTAssertEqual(sh.matrixCoefficients, 2) + XCTAssertFalse(sh.fullRange) + XCTAssertEqual(sh.maxWidth, 320) + XCTAssertEqual(sh.maxHeight, 180) + } + + func testSequenceHeaderRejectsTruncation() { + // The parse consumes exactly 79 bits of this header (it stops after + // chroma_sample_position — the last field av1C needs), so 10 bytes suffice and the + // 11th only carries fields past the parse. Everything shorter must fail cleanly. + let payload = Data(Self.keyframeTU[4..<15]) + for cut in 0..<10 { + XCTAssertNil( + AV1.parseSequenceHeader(payload.prefix(cut)), + "a header truncated to \(cut) bytes must not parse") + } + XCTAssertNotNil(AV1.parseSequenceHeader(payload.prefix(10))) + } + + // MARK: - Format description + + func testFormatDescriptionFromKeyframe() throws { + let format = try XCTUnwrap(AV1.formatDescription(fromKeyframe: Data(Self.keyframeTU))) + XCTAssertEqual(CMFormatDescriptionGetMediaSubType(format), kCMVideoCodecType_AV1) + let dims = CMVideoFormatDescriptionGetDimensions(format) + XCTAssertEqual(dims.width, 320) + XCTAssertEqual(dims.height, 180) + + // The av1C record: marker/version, profile+level, the packed flags byte (4:2:0, 8-bit, + // csp 0 → 0x0C), no presentation delay — then the sequence-header OBU verbatim. + let atoms = try XCTUnwrap( + CMFormatDescriptionGetExtension( + format, + extensionKey: kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms) + as? [String: Any]) + let av1C = try XCTUnwrap(atoms["av1C"] as? Data) + XCTAssertEqual([UInt8](av1C.prefix(4)), [0x81, 0x00, 0x0C, 0x00]) + XCTAssertEqual([UInt8](av1C.dropFirst(4)), [UInt8](Self.keyframeTU[2..<15]), + "configOBUs must be the size-fielded sequence-header OBU") + + // Unspecified color codes fall back to BT.709 studio range — and the transfer-function + // extension is what keeps VideoDecoder.isHDRFormat working for AV1. + let transfer = CMFormatDescriptionGetExtension( + format, extensionKey: kCMFormatDescriptionExtension_TransferFunction) as? String + XCTAssertEqual(transfer, kCMFormatDescriptionTransferFunction_ITU_R_709_2 as String) + XCTAssertFalse(VideoDecoder.isHDRFormat(format)) + } + + func testDeltaTUYieldsNoFormat() { + XCTAssertNil(AV1.formatDescription(fromKeyframe: Data(Self.deltaTU)), + "a delta TU has no sequence header — the pumps must latch the previous one") + } + + // MARK: - Sample repack + + func testSampleRepack() throws { + let format = try XCTUnwrap(AV1.formatDescription(fromKeyframe: Data(Self.keyframeTU))) + let au = AccessUnit( + data: Data(Self.keyframeTU), ptsNs: 1_000_000, frameIndex: 0, flags: 0, receivedNs: 0) + let sample = try XCTUnwrap(AV1.sampleBuffer(au: au, format: format)) + + // The blob is already fully size-fielded, so the repack is byte-identical minus the + // 2-byte temporal delimiter. + let block = try XCTUnwrap(CMSampleBufferGetDataBuffer(sample)) + var length = 0 + var ptr: UnsafeMutablePointer? + XCTAssertEqual(CMBlockBufferGetDataPointer( + block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, + dataPointerOut: &ptr), noErr) + let bytes = UnsafeRawBufferPointer(start: ptr, count: length) + XCTAssertEqual([UInt8](bytes), [UInt8](Self.keyframeTU[2...])) + + // No temporal delimiter survives, and the pts round-trips at nanosecond scale. + AV1.forEachOBU(in: Data(bytes)) { _, _, _, type in + XCTAssertNotEqual(type, AV1.OBUType.temporalDelimiter) + return true + } + let pts = CMSampleBufferGetPresentationTimeStamp(sample) + XCTAssertEqual(pts.value, 1_000_000) + XCTAssertEqual(pts.timescale, 1_000_000_000) + } + + func testSampleRepackDelimiterOnlyIsDropped() throws { + let format = try XCTUnwrap(AV1.formatDescription(fromKeyframe: Data(Self.keyframeTU))) + let au = AccessUnit( + data: Data([0x12, 0x00]), ptsNs: 0, frameIndex: 0, flags: 0, receivedNs: 0) + XCTAssertNil(AV1.sampleBuffer(au: au, format: format)) + } + + // MARK: - VideoCodec dispatch + + func testWireCodecResolution() { + XCTAssertEqual(VideoCodec(wire: 0x01), .h264) + XCTAssertEqual(VideoCodec(wire: 0x02), .hevc) + XCTAssertEqual(VideoCodec(wire: 0x04), .av1) + XCTAssertEqual(VideoCodec(wire: 0xFF), .hevc) // unknown → the default codec + } + + func testCodecDispatch() { + let au = Data(Self.keyframeTU) + XCTAssertNotNil(VideoCodec.av1.formatDescription(fromKeyframe: au)) + // The same bytes through the NAL paths must not parse — proves the dispatch matters. + XCTAssertNil(VideoCodec.hevc.formatDescription(fromKeyframe: au)) + XCTAssertNil(VideoCodec.h264.formatDescription(fromKeyframe: au)) + } + + // MARK: - Hardware decode (end to end) + + /// The AV1 counterpart of VideoToolboxRoundTripTests' decode half: the keyframe blob through + /// the REAL VideoDecoder (format description → repack → hardware VTDecompressionSession). + /// Pixels out = the whole AV1 decode path is sound. Skipped on devices without AV1 hardware + /// (exactly the devices the client never advertises AV1 from). + func testHardwareDecodeEndToEnd() throws { + try XCTSkipUnless( + AV1.hardwareDecodeSupported, "no AV1 hardware decoder — AV1 is never advertised here") + + let box = FrameBox() + let decoded = expectation(description: "decoded frame") + let decoder = VideoDecoder( + onDecoded: { frame in + box.lock.lock() + box.frame = frame + box.lock.unlock() + decoded.fulfill() + }, + onDecodeError: { status in + box.lock.lock() + box.error = status + box.lock.unlock() + decoded.fulfill() + }) + decoder.setCodec(.av1) + + let format = try XCTUnwrap(AV1.formatDescription(fromKeyframe: Data(Self.keyframeTU))) + let au = AccessUnit( + data: Data(Self.keyframeTU), ptsNs: 42_000_000, frameIndex: 0, flags: 0, receivedNs: 1) + XCTAssertTrue(decoder.decode(au: au, format: format), "hardware session must accept the keyframe") + wait(for: [decoded], timeout: 5) + + box.lock.lock() + let frame = box.frame + let error = box.error + box.lock.unlock() + XCTAssertNil(error.map { "decode error \($0)" }) + let ready = try XCTUnwrap(frame) + XCTAssertEqual(ready.ptsNs, 42_000_000) + XCTAssertFalse(ready.isHDR) + XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 320) + XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 180) + XCTAssertEqual( + CVPixelBufferGetPixelFormatType(ready.pixelBuffer), + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, "SDR AV1 must decode to NV12") + decoder.reset() + } + + // MARK: - Blobs + + /// Keyframe temporal unit (740 bytes). + static let keyframeTU: [UInt8] = [ + 0x12, 0x00, 0x0a, 0x0b, 0x00, 0x00, 0x00, 0x04, 0x3c, 0xfe, 0xcc, 0x4a, 0xf9, 0x00, 0x40, 0x32, + 0xd2, 0x05, 0x10, 0x00, 0x9b, 0xa0, 0x8f, 0xbe, 0x7d, 0xf0, 0xdf, 0xbc, 0xf8, 0x00, 0xdd, 0x6d, + 0x69, 0x7f, 0xb3, 0x26, 0x63, 0x5e, 0x79, 0xf4, 0xf5, 0xc5, 0x84, 0xda, 0xcf, 0xec, 0xd8, 0xa6, + 0xc1, 0x56, 0x99, 0x43, 0xf0, 0xff, 0x31, 0xd5, 0x41, 0xd8, 0xbb, 0x07, 0x10, 0x5e, 0x42, 0xc5, + 0x9b, 0x63, 0xc0, 0x14, 0xe7, 0x28, 0x73, 0xf3, 0x50, 0x12, 0x02, 0x8e, 0x2b, 0x91, 0xd7, 0x3d, + 0xc5, 0x33, 0xc9, 0x9b, 0xd9, 0xea, 0xfb, 0xc3, 0x5a, 0xdd, 0xb3, 0x0b, 0x5d, 0xc6, 0xde, 0x1a, + 0xca, 0x90, 0x61, 0x0a, 0x77, 0x83, 0xb5, 0x8f, 0x9a, 0x88, 0xf0, 0x3d, 0xa2, 0x78, 0x81, 0x6c, + 0xb6, 0x8e, 0x85, 0x90, 0x44, 0xa1, 0xda, 0xa8, 0xf6, 0xcb, 0xa2, 0xf2, 0xb8, 0xb9, 0xac, 0x6b, + 0xba, 0xfd, 0x8f, 0x7e, 0x04, 0x32, 0x0d, 0x90, 0xed, 0x5a, 0xe2, 0x1d, 0x8a, 0x03, 0x29, 0x47, + 0xde, 0xf6, 0x9a, 0xd4, 0x45, 0x91, 0x17, 0x5c, 0x7b, 0xdf, 0xa3, 0xe2, 0x7a, 0xd9, 0x93, 0xfd, + 0x55, 0xeb, 0xd9, 0x3f, 0xa6, 0xec, 0xdd, 0x2a, 0x00, 0xbc, 0x8d, 0x1d, 0x98, 0xa2, 0x39, 0x88, + 0x3f, 0x32, 0x3e, 0x18, 0x85, 0x12, 0x46, 0x57, 0x1d, 0x25, 0x8e, 0xcc, 0x41, 0x37, 0xc3, 0x9f, + 0xbd, 0x7b, 0xf9, 0xb9, 0xa4, 0x9d, 0x86, 0xf4, 0xcc, 0x2b, 0x5a, 0xf3, 0xbc, 0x77, 0x80, 0xcb, + 0xc4, 0xf3, 0x02, 0x91, 0xf6, 0xb8, 0x59, 0x93, 0x33, 0xbe, 0xe2, 0x23, 0xac, 0xb9, 0xe2, 0x69, + 0x67, 0xad, 0x63, 0x45, 0x35, 0x94, 0x9e, 0x2e, 0xfa, 0x2b, 0xb9, 0xc3, 0xf9, 0x39, 0x5e, 0xa8, + 0x33, 0x4d, 0xf3, 0x5c, 0xbd, 0xe6, 0x5b, 0x50, 0x19, 0xe6, 0xd3, 0xf2, 0x01, 0xcf, 0x35, 0x09, + 0xd6, 0x2a, 0x67, 0x17, 0xd2, 0xbd, 0x91, 0xc3, 0x91, 0x17, 0x4a, 0xbc, 0x29, 0xf0, 0xb8, 0xd4, + 0xfc, 0x04, 0xac, 0x63, 0xfb, 0x2f, 0xc5, 0xe9, 0xb2, 0x06, 0xac, 0x3c, 0x79, 0x33, 0x5c, 0x73, + 0x80, 0x95, 0x0f, 0xad, 0xff, 0xee, 0xed, 0x78, 0xaf, 0xc6, 0x1b, 0xb4, 0xc2, 0x96, 0x5f, 0x7f, + 0x20, 0x5f, 0xb6, 0xdb, 0x70, 0xab, 0x60, 0x0b, 0xea, 0xd1, 0xaf, 0x57, 0x71, 0xeb, 0x3b, 0xef, + 0xb1, 0x3c, 0x01, 0x72, 0x5b, 0x59, 0x6d, 0x36, 0xe3, 0x16, 0xda, 0x0a, 0x6b, 0xc7, 0x0b, 0xa0, + 0xa6, 0x6f, 0x77, 0x4f, 0x0b, 0xe6, 0x62, 0x34, 0x0e, 0xdd, 0xfa, 0xbe, 0x4f, 0x67, 0x21, 0x40, + 0xc2, 0xcd, 0xf3, 0x63, 0x9e, 0xb2, 0x28, 0xaf, 0x5b, 0x0e, 0x28, 0xba, 0x91, 0xec, 0xec, 0xf2, + 0xf4, 0xdb, 0x9c, 0x51, 0x24, 0x67, 0x77, 0xe0, 0x67, 0x70, 0x51, 0xfb, 0x00, 0x61, 0x50, 0x9f, + 0xae, 0x5e, 0x96, 0x2d, 0x1e, 0xa2, 0xab, 0x49, 0x90, 0x90, 0x1c, 0x04, 0x93, 0x8b, 0xc2, 0xee, + 0x53, 0x61, 0x62, 0x19, 0x62, 0x5c, 0xff, 0x15, 0x84, 0x7a, 0x5c, 0x70, 0xaa, 0x6d, 0x39, 0xb2, + 0xe9, 0x19, 0xd1, 0x9f, 0xf3, 0xb3, 0xb7, 0x05, 0xd2, 0xef, 0x5f, 0xe9, 0x2a, 0x25, 0x55, 0x0a, + 0xf3, 0xd2, 0x95, 0xba, 0x22, 0x5f, 0x49, 0x9b, 0x5d, 0xae, 0xeb, 0x51, 0x63, 0x51, 0xa0, 0x85, + 0xd6, 0xb6, 0x23, 0x6f, 0x92, 0xbf, 0x99, 0xf3, 0xf9, 0xbf, 0x07, 0xd4, 0x05, 0x1c, 0x6b, 0xe1, + 0x42, 0x49, 0xfe, 0x99, 0x4c, 0x6f, 0x34, 0xea, 0x29, 0x14, 0xd5, 0x92, 0x17, 0xfa, 0xc4, 0x35, + 0xef, 0x97, 0x31, 0x06, 0xdc, 0xc7, 0x57, 0xe7, 0x79, 0x3d, 0x64, 0xf3, 0x12, 0x0d, 0x60, 0x5d, + 0x9d, 0xa5, 0x11, 0xb9, 0xf9, 0x75, 0x3a, 0x59, 0x42, 0x2a, 0xd0, 0xed, 0xb4, 0xa8, 0xee, 0x87, + 0x9e, 0x3d, 0x52, 0x47, 0x6c, 0x8f, 0x43, 0x98, 0xd0, 0x72, 0x8b, 0x16, 0xef, 0x2a, 0xaa, 0x9c, + 0x42, 0x91, 0xc8, 0x92, 0x85, 0x79, 0xc0, 0xf8, 0xc0, 0x12, 0x44, 0x38, 0x49, 0xdb, 0x5f, 0xfc, + 0x7b, 0x6e, 0xac, 0xea, 0x38, 0x3c, 0x3f, 0x49, 0xc2, 0xe7, 0x82, 0xb3, 0x30, 0xf2, 0x7e, 0x31, + 0xc2, 0xf8, 0xfc, 0x9a, 0x9e, 0x6d, 0x4c, 0x5f, 0xd4, 0xc0, 0x22, 0xd3, 0x40, 0xc9, 0x66, 0x59, + 0x38, 0x14, 0x64, 0x24, 0xc0, 0x0d, 0x56, 0x88, 0x6d, 0x9f, 0x80, 0x71, 0xc8, 0x26, 0x3e, 0x2f, + 0xd1, 0xd2, 0x6d, 0x8a, 0xf2, 0x2c, 0x01, 0xbf, 0x89, 0x15, 0xc7, 0x66, 0x3d, 0x19, 0x9f, 0xb8, + 0x4c, 0xb9, 0x6f, 0xd7, 0xe8, 0x59, 0xf3, 0xe7, 0xdd, 0x14, 0x3e, 0x99, 0x37, 0x90, 0xb5, 0x2d, + 0x49, 0xcc, 0x40, 0xd6, 0xe1, 0x29, 0x4e, 0x31, 0x7c, 0xef, 0xdb, 0x74, 0xf0, 0x9f, 0xa6, 0xdd, + 0xbc, 0xd9, 0x65, 0x0f, 0xf7, 0x22, 0xa8, 0xd0, 0xfb, 0x78, 0x49, 0x40, 0xbf, 0x96, 0xa1, 0x5a, + 0x7b, 0xc6, 0x60, 0x63, 0x22, 0xad, 0x5d, 0x97, 0xa2, 0x77, 0x3f, 0x58, 0x3b, 0x94, 0xa4, 0xd9, + 0xe3, 0xe5, 0xae, 0x0c, 0x30, 0x91, 0xdd, 0x5a, 0xbb, 0xfd, 0x10, 0x6a, 0x22, 0x8f, 0xbd, 0x8c, + 0x41, 0xf5, 0xa2, 0x29, 0xd7, 0xad, 0x4e, 0x58, 0xfb, 0x1e, 0x9a, 0x0e, 0x98, 0x54, 0xc1, 0xd7, + 0xfb, 0xdf, 0xcc, 0x1d, 0x5d, 0xe3, 0x25, 0x6b, 0x57, 0x69, 0x67, 0x80, 0x0c, 0xb5, 0xcb, 0x01, + 0x5a, 0x56, 0x56, 0x01, 0x47, 0xad, 0x6b, 0x26, 0x28, 0x30, 0x36, 0x79, 0x91, 0x62, 0x52, 0x93, + 0xa4, 0xe8, 0x52, 0x30, + ] + + /// Delta temporal unit (27 bytes). + static let deltaTU: [UInt8] = [ + 0x12, 0x00, 0x32, 0x17, 0x30, 0x02, 0x04, 0x09, 0x24, 0x92, 0x22, 0x7f, 0x80, 0x00, 0x01, 0x9f, + 0x00, 0x00, 0x00, 0x8b, 0x07, 0x27, 0x7a, 0x64, 0x4c, 0xec, 0xf4, + ] +}