Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ 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.

## [3.0.3] — 2026-10-22

### 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.
- 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

### Fixed
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
]
```

Expand Down
16 changes: 7 additions & 9 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion Sources/CLI/SwiftMediaMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
153 changes: 147 additions & 6 deletions Sources/SwiftMediaMetadata/Video/MXFReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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) }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<C: MXFByteCursor>(_ 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<C: MXFByteCursor>(_ 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<C: MXFByteCursor>(_ 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<C: MXFByteCursor>(
_ 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.
Expand Down Expand Up @@ -799,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
Expand Down
74 changes: 74 additions & 0 deletions Tests/SwiftMediaMetadataTests/Video/MXFFileCursorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<frames {
essence += klv(picture, Data(repeating: 0x11, count: 2048)) + klv(sound, Data(repeating: 0x22, count: 256))
}
let headerLength = pack(0x02, this: 0, previous: 0, footer: 0).count
let footerStart = headerLength + essence.count
var data = pack(0x02, this: 0, previous: 0, footer: footerPointer ? footerStart : 0) + essence
data += pack(0x04, this: footerStart, previous: 0, footer: footerStart)
data += klv(Data(repeating: 0xAA, count: 16), xml)
if rip {
let entries = Data([0, 0, 0, 1]) + u64(0) + Data([0, 0, 0, 0]) + u64(footerStart)
let ripKey = Data([0x06,0x0E,0x2B,0x34,0x02,0x05,0x01,0x01,0x0D,0x01,0x02,0x01,0x01,0x11,0x01,0x00])
let length = entries.count + 4
let total = 16 + ber(length).count + length
data += ripKey + ber(length) + entries + withUnsafeBytes(of: UInt32(total).bigEndian) { Data($0) }
}
return (data, footerStart)
}

private func parseCountingReads(_ data: Data) throws -> (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..<entryOffset + 8, with: withUnsafeBytes(of: UInt64(300).bigEndian) { Data($0) })
XCTAssertNil({ var r = BinaryReader(data: data); return MXFReader.partitionOffsets(&r) }())
let metadata = try parseMXFWithFileParity(data)
XCTAssertEqual(metadata.camera?.deviceModelName, "Late FX6")
}

func testLateDurationAndTimecodeEncounterOrder() throws {
func timecode(_ start: UInt64) -> Data {
local(0x1501, withUnsafeBytes(of: start.bigEndian) { Data($0) })
Expand Down
20 changes: 20 additions & 0 deletions Tests/SwiftMediaMetadataTests/Video/MXFReaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading