From f3d60b38d816af609860d7a6e8c7f500841cf8d9 Mon Sep 17 00:00:00 2001 From: Truls Aagedal <5827165+aagedal@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:43:02 +0200 Subject: [PATCH 1/3] Skip MXF essence via partition index during metadata parse Frame-wrapped XAVC/XDCAM files carry ~10 KLVs per frame, so the KLV walk issued reads across the whole file. Use the Random Index Pack (or the footer PreviousPartition chain) to jump from a partition's first essence KLV to the next partition, skip essence without peeking, and bound the NRT/ARRI fallback scan to the header metadata. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++ .../SwiftMediaMetadata/Video/MXFReader.swift | 142 +++++++++++++++++- .../Video/MXFFileCursorTests.swift | 74 +++++++++ 3 files changed, 222 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 830e7cd..08ea399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/ Version numbers follow [Semantic Versioning](https://semver.org/) and track the CLI; the library target follows the same numbering. +## [Unreleased] + +### Fixed + +- MXF parsing now jumps over essence using the Random Index Pack (or the + footer's PreviousPartition chain) instead of visiting every frame's KLVs. + Frame-wrapped XAVC/XDCAM clips previously cost one small read per KLV across + the whole file — seconds on SSD and far longer on camera cards; a 16 GB + XAVC clip now parses in ~20 ms. Essence KLVs are also no longer peeked, and + the NRT/ARRI fallback scan is limited to the header metadata. Files without + a valid partition index keep the previous full walk. + ## [3.0.2] — 2026-10-02 ### Fixed diff --git a/Sources/SwiftMediaMetadata/Video/MXFReader.swift b/Sources/SwiftMediaMetadata/Video/MXFReader.swift index aed8c47..1ed930a 100644 --- a/Sources/SwiftMediaMetadata/Video/MXFReader.swift +++ b/Sources/SwiftMediaMetadata/Video/MXFReader.swift @@ -60,9 +60,11 @@ public struct MXFReader: Sendable { return try parse(cursor: &reader) } - /// Read an MXF without mapping or retaining its essence payload. The full - /// KLV walk still includes metadata after essence; only values classified - /// as metadata (at most 32 MiB) and the 16 MiB fallback prefix are loaded. + /// Read an MXF without mapping or retaining its essence payload. When the + /// partition layout is known (RIP or footer chain) the walk jumps from a + /// partition's first essence KLV to the next partition, so metadata after + /// essence is still read; only values classified as metadata (at most + /// 32 MiB) and the header-metadata fallback prefix are loaded. public static func parse(from url: URL) throws -> VideoMetadata { var reader = try MXFFileCursor(url: url) let metadata = try parse(cursor: &reader) @@ -89,8 +91,16 @@ public struct MXFReader: Sendable { // tag map + per-subdescriptor parsed fields + per-track SubDescriptors // UID arrays). Filled inside the loop, resolved after the loop. var mca = MCAState() + // Partition offsets (from the Random Index Pack or the footer's + // PreviousPartition chain) let the walk jump over a partition's + // essence instead of visiting every frame's KLVs. Frame-wrapped + // XAVC/XDCAM files carry ~10 KLVs per frame, so without this a + // probe touches the whole file — slow on camera cards. + let partitions = partitionOffsets(&reader) + var firstEssenceOffset: Int? while reader.remainingCount >= 17 { + let keyStart = reader.offset let key: Data let length: Int do { @@ -106,6 +116,19 @@ public struct MXFReader: Sendable { guard length <= reader.remainingCount else { break } let valueStart = reader.offset + + // Essence never carries the metadata we read: skip it without a + // peek, and when partitions are known skip to the next partition. + if isEssenceElementKey(key), !isC2PAKey(key) { + if firstEssenceOffset == nil { firstEssenceOffset = keyStart } + if let partitions { + guard let next = partitions.first(where: { $0 > keyStart }) else { break } + try reader.seek(to: next) + } else { + try reader.seek(to: valueStart + length) + } + continue + } let peekCount = min(length, klvPeekBytes) let peek: Data do { peek = try reader.slice(from: valueStart, count: peekCount) } @@ -238,9 +261,11 @@ public struct MXFReader: Sendable { } } - // Both fallbacks already restrict their search to the first 16 MiB. - // Materialize that prefix once, after KLV scratch values are released. - let prefix = try reader.slice(from: 0, count: min(reader.count, nrtScanWindow)) + // Both fallbacks already restrict their search to the first 16 MiB, + // and to the header metadata when essence was seen. Materialize that + // prefix once, after KLV scratch values are released. + let prefixLimit = min(reader.count, nrtScanWindow, firstEssenceOffset ?? .max) + let prefix = try reader.slice(from: 0, count: prefixLimit) // Fallback: Sony XDCAM/XAVC writers often wrap NRT XML inside an // RP 2057 XML Document Set whose value is a local-tag/length/value // sequence — the XML bytes therefore live *inside* a KLV value, not @@ -525,6 +550,111 @@ public struct MXFReader: Sendable { } } + // MARK: - Partitions + + /// Partition pack key prefix (SMPTE 377-1): header 0x02, body 0x03, + /// footer 0x04 in byte 13. + private static let partitionPackPrefix: [UInt8] = [ + 0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, + 0x0D, 0x01, 0x02, 0x01, 0x01, + ] + + /// Random Index Pack key (SMPTE 377-1 section 12). + private static let randomIndexPackKey: [UInt8] = [ + 0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, + 0x0D, 0x01, 0x02, 0x01, 0x01, 0x11, 0x01, 0x00, + ] + + /// MXF Generic Container essence element (picture, sound, data and + /// system items): 06.0E.2B.34.xx.xx.xx.xx.0D.01.03.01. + static func isEssenceElementKey(_ key: Data) -> Bool { + guard key.count >= 12 else { return false } + let s = key.startIndex + return key[s] == 0x06 && key[s + 1] == 0x0E && key[s + 2] == 0x2B && key[s + 3] == 0x34 + && key[s + 8] == 0x0D && key[s + 9] == 0x01 && key[s + 10] == 0x03 && key[s + 11] == 0x01 + } + + /// Sorted absolute offsets of every partition after the header, or nil + /// when they can't be established reliably (then the walk visits every + /// KLV as before). Offsets are validated against partition pack keys. + static func partitionOffsets(_ reader: inout C) -> [Int]? { + let offsets = ripPartitionOffsets(&reader) ?? footerChainPartitionOffsets(&reader) + guard let offsets else { return nil } + let sorted = Array(Set(offsets)).sorted() + for offset in sorted { + guard offset > 0, offset <= reader.count - 16, + let key = try? reader.slice(from: offset, count: 16), + hasPrefix(key, partitionPackPrefix) else { return nil } + } + return sorted + } + + private static func ripPartitionOffsets(_ reader: inout C) -> [Int]? { + guard reader.count >= 20, + let tail = try? reader.slice(from: reader.count - 4, count: 4) else { return nil } + let ripLength = tail.reduce(0) { ($0 << 8) | Int($1) } + guard ripLength >= 20, ripLength <= reader.count, + let rip = try? reader.slice(from: reader.count - ripLength, count: ripLength), + hasPrefix(rip, randomIndexPackKey) else { return nil } + var ripReader = BinaryReader(data: rip) + guard (try? ripReader.seek(to: 16)) != nil, + let length = try? readBERLength(&ripReader), + length == ripReader.remainingCount, length >= 4, (length - 4) % 12 == 0 else { return nil } + var offsets: [Int] = [] + for _ in 0..<((length - 4) / 12) { + guard (try? ripReader.readBytes(4)) != nil, + let raw = try? ripReader.readBytes(8) else { return nil } + let offset = raw.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } + guard offset <= UInt64(Int.max) else { return nil } + offsets.append(Int(offset)) + } + return offsets.filter { $0 > 0 } + } + + /// Fallback for files without a RIP: follow PreviousPartition links back + /// from the footer named in the header partition pack. + private static func footerChainPartitionOffsets(_ reader: inout C) -> [Int]? { + guard let header = partitionPackFields(&reader, at: 0), + header.footer > 0 else { return nil } + var offsets: [Int] = [] + var current = header.footer + while current > 0 { + guard offsets.count < 100_000, !offsets.contains(current), + let fields = partitionPackFields(&reader, at: current), + fields.this == current, fields.previous < current else { return nil } + offsets.append(current) + current = fields.previous + } + return offsets + } + + private static func partitionPackFields( + _ reader: inout C, at offset: Int + ) -> (this: Int, previous: Int, footer: Int)? { + // Key (16) + BER length (up to 9) + version/KAG (8) + 3 × UInt64. + guard offset >= 0, offset <= reader.count - 16, + let bytes = try? reader.slice(from: offset, count: min(64, reader.count - offset)), + hasPrefix(bytes, partitionPackPrefix) else { return nil } + var packReader = BinaryReader(data: bytes) + guard (try? packReader.seek(to: 16)) != nil, + (try? readBERLength(&packReader)) != nil, + (try? packReader.readBytes(8)) != nil, + let fields = try? packReader.readBytes(24) else { return nil } + func value(_ index: Int) -> Int? { + let raw = fields.dropFirst(index * 8).prefix(8).reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } + return raw <= UInt64(Int.max) ? Int(raw) : nil + } + guard let this = value(0), let previous = value(1), let footer = value(2) else { return nil } + return (this, previous, footer) + } + + private static func hasPrefix(_ data: Data, _ prefix: [UInt8]) -> Bool { + guard data.count >= prefix.count else { return false } + let s = data.startIndex + for (i, b) in prefix.enumerated() where data[s + i] != b { return false } + return true + } + // MARK: - BER length /// Decode a SMPTE ST 379 / BER-encoded length field. diff --git a/Tests/SwiftMediaMetadataTests/Video/MXFFileCursorTests.swift b/Tests/SwiftMediaMetadataTests/Video/MXFFileCursorTests.swift index c8be8db..f4463b2 100644 --- a/Tests/SwiftMediaMetadataTests/Video/MXFFileCursorTests.swift +++ b/Tests/SwiftMediaMetadataTests/Video/MXFFileCursorTests.swift @@ -83,6 +83,80 @@ final class MXFFileCursorTests: XCTestCase { } } + /// Frame-wrapped layout: header partition with essence (one picture + + /// one sound KLV per frame), then a footer partition carrying metadata. + private func frameWrappedMXF(frames: Int, rip: Bool, footerPointer: Bool) -> (data: Data, footerStart: Int) { + func u64(_ v: Int) -> Data { withUnsafeBytes(of: UInt64(v).bigEndian) { Data($0) } } + func pack(_ kind: UInt8, this: Int, previous: Int, footer: Int) -> Data { + var value = Data([0, 1, 0, 3, 0, 0, 0, 1]) + value += u64(this) + u64(previous) + u64(footer) + value += u64(0) + u64(0) + Data([0, 0, 0, 0]) + value += u64(0) + Data([0, 0, 0, 1]) + value += Data(repeating: 0, count: 16) + Data([0, 0, 0, 0, 0, 0, 0, 16]) + return klv(Data([0x06,0x0E,0x2B,0x34,0x02,0x05,0x01,0x01,0x0D,0x01,0x02,0x01,0x01,kind,0x04,0x00]), value) + } + let picture = Data([0x06,0x0E,0x2B,0x34,0x01,0x02,0x01,0x01,0x0D,0x01,0x03,0x01,0x15,0x01,0x05,0x01]) + let sound = Data([0x06,0x0E,0x2B,0x34,0x01,0x02,0x01,0x01,0x0D,0x01,0x03,0x01,0x16,0x01,0x01,0x01]) + var essence = Data() + for _ in 0.. (VideoMetadata, requests: [(Int, Int)]) { + var result: (VideoMetadata, [(Int, Int)])! + try withFile(data) { url in + var cursor = try MXFFileCursor(url: url) + var reads: [(Int, Int)] = [] + cursor.onRead = { reads.append(($0, $1)) } + result = (try MXFReader.parse(cursor: &cursor), reads) + } + return result + } + + func testFrameWrappedEssenceSkippedViaRandomIndexPack() throws { + let (data, footerStart) = frameWrappedMXF(frames: 500, rip: true, footerPointer: false) + let (metadata, reads) = try parseCountingReads(data) + XCTAssertEqual(metadata.camera?.deviceModelName, "Late FX6") + // Only the first essence KLV's key and BER length are read. + let firstEssenceKLVEnd = 16 + 1 + 88 + 16 + 3 + XCTAssertFalse(reads.contains { $0.0 >= firstEssenceKLVEnd && $0.0 < footerStart }, + "No read may land inside the skipped essence") + XCTAssertLessThan(reads.count, 40) + try assertMXFFileParity(data) + } + + func testFrameWrappedEssenceSkippedViaFooterChainWithoutRIP() throws { + let (data, footerStart) = frameWrappedMXF(frames: 500, rip: false, footerPointer: true) + let (metadata, reads) = try parseCountingReads(data) + XCTAssertEqual(metadata.camera?.deviceModelName, "Late FX6") + XCTAssertFalse(reads.contains { $0.0 >= 16 + 1 + 88 + 16 + 3 && $0.0 < footerStart }) + try assertMXFFileParity(data) + } + + func testInvalidRIPFallsBackToFullWalk() throws { + var (data, _) = frameWrappedMXF(frames: 20, rip: true, footerPointer: false) + // Point the footer entry at the middle of the essence. + let entryOffset = data.count - 4 - 8 + data.replaceSubrange(entryOffset.. Data { local(0x1501, withUnsafeBytes(of: start.bigEndian) { Data($0) }) From 6494c9000150b0389eeeabc679d356009bd559d2 Mon Sep 17 00:00:00 2001 From: Truls Aagedal <5827165+aagedal@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:46:51 +0200 Subject: [PATCH 2/3] Report MXF display rectangle instead of padded stored height XAVC/AVC-Intra 1080p descriptors store 1920x1088 (macroblock padding) with a 1920x1080 display rectangle. Using the stored height produced 1088 and a 136:135 SAR; ffprobe reports 1920x1080 1:1. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ .../SwiftMediaMetadata/Video/MXFReader.swift | 11 ++++++++++ .../Video/MXFReaderTests.swift | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ea399..f62814d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ the CLI; the library target follows the same numbering. XAVC clip now parses in ~20 ms. Essence KLVs are also no longer peeked, and the NRT/ARRI fallback scan is limited to the header metadata. Files without a valid partition index keep the previous full walk. +- MXF picture dimensions now use the descriptor's display rectangle when it is + smaller than the stored (coding-block padded) one, matching ffprobe. XAVC + 1080p was reported as 1920×1088 with a 136:135 pixel aspect ratio. ## [3.0.2] — 2026-10-02 diff --git a/Sources/SwiftMediaMetadata/Video/MXFReader.swift b/Sources/SwiftMediaMetadata/Video/MXFReader.swift index 1ed930a..acadf0b 100644 --- a/Sources/SwiftMediaMetadata/Video/MXFReader.swift +++ b/Sources/SwiftMediaMetadata/Video/MXFReader.swift @@ -929,6 +929,17 @@ public struct MXFReader: Sendable { } } + // Stored dimensions are padded to whole coding blocks (AVC/XAVC + // 1920×1080 is stored as 1920×1088). Like ffprobe, report the + // displayed picture when the descriptor carries a smaller display + // rectangle; otherwise DAR/SAR below derive from the padded height. + if let dw = stream.displayWidth, let dh = stream.displayHeight, + let sw = stream.width, let sh = stream.height, + dw > 0, dh > 0, dw <= sw, dh <= sh { + stream.width = dw + stream.height = dh + } + if let fl = frameLayout { stream.fieldOrder = fieldOrderFromLayout(fl, dominance: fieldDominance) // FrameLayout 1 (SeparateFields) / 2 (OneField) report StoredHeight diff --git a/Tests/SwiftMediaMetadataTests/Video/MXFReaderTests.swift b/Tests/SwiftMediaMetadataTests/Video/MXFReaderTests.swift index 5466784..4fe8d1f 100644 --- a/Tests/SwiftMediaMetadataTests/Video/MXFReaderTests.swift +++ b/Tests/SwiftMediaMetadataTests/Video/MXFReaderTests.swift @@ -53,6 +53,26 @@ final class MXFReaderTests: XCTestCase { XCTAssertEqual(tags, [0x0001]) } + // MARK: - Picture descriptor — stored vs display rectangle + + func testParsePictureDescriptorReportsDisplayRectangleOverPaddedStoredHeight() { + func u32(_ v: UInt32) -> Data { withUnsafeBytes(of: v.bigEndian) { Data($0) } } + // XAVC 1080p: StoredHeight is macroblock-padded to 1088. + var body = localTagLV(0x3202, value: u32(1088)) + body.append(localTagLV(0x3203, value: u32(1920))) + body.append(localTagLV(0x3208, value: u32(1080))) + body.append(localTagLV(0x3209, value: u32(1920))) + body.append(localTagLV(0x320E, value: u32(16) + u32(9))) + var stream = VideoStream(index: 0) + var duration: TimeInterval? = nil + MXFReader.parsePictureDescriptor(body, into: &stream, duration: &duration) + XCTAssertEqual(stream.width, 1920) + XCTAssertEqual(stream.height, 1080) + XCTAssertEqual(stream.displayWidth, 1920) + XCTAssertEqual(stream.pixelAspectRatio?.0, 1) + XCTAssertEqual(stream.pixelAspectRatio?.1, 1) + } + // MARK: - B. Picture descriptor — codec UL mapping func testParsePictureDescriptorMapsAVCULToAvc1() { From 2b473865bf6046b1553660964d9d37cead709b05 Mon Sep 17 00:00:00 2001 From: Truls Aagedal <5827165+aagedal@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:58:42 +0200 Subject: [PATCH 3/3] Prepare SwiftMediaMetadata 3.0.3 release Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 8 ++++---- RELEASE_CHECKLIST.md | 16 +++++++--------- Sources/CLI/SwiftMediaMetadata.swift | 2 +- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f62814d..0bb8d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/ Version numbers follow [Semantic Versioning](https://semver.org/) and track the CLI; the library target follows the same numbering. -## [Unreleased] +## [3.0.3] — 2026-10-22 ### Fixed diff --git a/README.md b/README.md index bfe12d1..5a4ae3e 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,11 @@ swift-exif --version ``` Or download the archive and checksum directly from the -[3.0.2 release](https://github.com/aagedal/SwiftMediaMetadata/releases/tag/3.0.2): +[3.0.3 release](https://github.com/aagedal/SwiftMediaMetadata/releases/tag/3.0.3): ```sh -curl -LO https://github.com/aagedal/SwiftMediaMetadata/releases/download/3.0.2/swift-exif-macos-arm64.tar.gz -curl -LO https://github.com/aagedal/SwiftMediaMetadata/releases/download/3.0.2/swift-exif-macos-arm64.tar.gz.sha256 +curl -LO https://github.com/aagedal/SwiftMediaMetadata/releases/download/3.0.3/swift-exif-macos-arm64.tar.gz +curl -LO https://github.com/aagedal/SwiftMediaMetadata/releases/download/3.0.3/swift-exif-macos-arm64.tar.gz.sha256 shasum -a 256 -c swift-exif-macos-arm64.tar.gz.sha256 tar -xzf swift-exif-macos-arm64.tar.gz ./swift-exif-macos-arm64/swift-exif --version @@ -83,7 +83,7 @@ Add the package to your `Package.swift` with: ```swift dependencies: [ - .package(url: "https://github.com/aagedal/SwiftMediaMetadata.git", from: "3.0.2"), + .package(url: "https://github.com/aagedal/SwiftMediaMetadata.git", from: "3.0.3"), ] ``` diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index cff3958..7e53648 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,17 +1,15 @@ -# SwiftMediaMetadata 3.0.2 release checklist +# SwiftMediaMetadata 3.0.3 release checklist -- [x] Preserve the upstream manifest, Benchmark and ArgumentParser lockfile. -- [x] Retain bounded-MXF source and regression coverage from tested commit - `8297324bb00ad1358b4070575c1ea59e698d3f2f`. -- [x] Set CLI version, README and changelog to 3.0.2. +- [x] MXF essence skipped via the Random Index Pack / footer partition chain, + with fallback to the full KLV walk when no valid partition index exists. +- [x] MXF picture dimensions use the display rectangle (XAVC 1920x1080, not 1088). +- [x] Metadata parity against 3.0.2 on 63 local MXF files (Sony XAVC/XDCAM, + FX6, Avid, DJI, Premiere transcodes); video dimensions/SAR match ffprobe. +- [x] Set CLI version, README and changelog to 3.0.3. - [ ] Run complete release preflight on the final release tree. - [ ] Merge the reviewed release changes into `main` before tagging. The release workflow rejects tags whose commits are not in `main`'s history. - [ ] Publish the semantic-version tag and verify CI release checks. -- [ ] Download and verify the published archive and CLI/resource smoke checks. - [ ] Resolve and pin the downstream application from a fresh package cache. -- [ ] Repeat authentic-media application profiles against the published package. Historical local library/CLI tests do not replace final-tree release validation. -Real-volume I/O, multi-hour scaling, same-size concurrent mutation, Linux, -supported macOS and base-M1 application performance remain separate acceptance. diff --git a/Sources/CLI/SwiftMediaMetadata.swift b/Sources/CLI/SwiftMediaMetadata.swift index 68829eb..c658379 100644 --- a/Sources/CLI/SwiftMediaMetadata.swift +++ b/Sources/CLI/SwiftMediaMetadata.swift @@ -6,7 +6,7 @@ struct SwiftMediaMetadataCLI: ParsableCommand { static let configuration = CommandConfiguration( commandName: "swift-exif", abstract: "Read, write, and manipulate media metadata.", - version: "3.0.2", + version: "3.0.3", subcommands: [ ReadCommand.self, WriteCommand.self,