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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ 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.1.0] — 2026-10-30

### Added

- Matroska `Attachments` are now exposed as `VideoMetadata.attachments`
(`AttachmentStream`: file name, MIME type, description, size, UID). Indices
follow ffmpeg's attachment stream numbering, so callers can address fonts
shipped for ASS/SSA subtitles with `t:<index>` specifiers.

## [3.0.3] — 2026-10-22

### 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.3 release](https://github.com/aagedal/SwiftMediaMetadata/releases/tag/3.0.3):
[3.1.0 release](https://github.com/aagedal/SwiftMediaMetadata/releases/tag/3.1.0):

```sh
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
curl -LO https://github.com/aagedal/SwiftMediaMetadata/releases/download/3.1.0/swift-exif-macos-arm64.tar.gz
curl -LO https://github.com/aagedal/SwiftMediaMetadata/releases/download/3.1.0/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.3"),
.package(url: "https://github.com/aagedal/SwiftMediaMetadata.git", from: "3.1.0"),
]
```

Expand Down
15 changes: 7 additions & 8 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
# SwiftMediaMetadata 3.0.3 release checklist
# SwiftMediaMetadata 3.1.0 release checklist

- [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.
- [x] Matroska `Attachments` exposed as `VideoMetadata.attachments`, skipping
incomplete entries so indices match ffmpeg's `t:<n>` attachment streams.
- [x] Verified against an ffmpeg-muxed MKV with three font attachments (names,
MIME types and payload sizes match ffmpeg).
- [x] Set CLI version, README and changelog to 3.1.0.
- [x] 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.
Expand Down
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.3",
version: "3.1.0",
subcommands: [
ReadCommand.self,
WriteCommand.self,
Expand Down
5 changes: 5 additions & 0 deletions Sources/SwiftMediaMetadata/API/VideoMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ public struct VideoMetadata: Sendable {
/// text-track chapters; Matroska `Chapters` master element). Ordered by
/// `startTime`. Empty when the container exposes no chapter information.
public var chapters: [VideoChapter]
/// Files attached to the container (Matroska `Attachments`), typically
/// fonts referenced by ASS/SSA subtitles. Ordered as ffmpeg exposes them
/// as `codec_type=attachment` streams. Empty for other containers.
public var attachments: [AttachmentStream]
/// Overall container bit rate in bits/second, when the container advertises one.
public var bitRate: Int?
public var title: String?
Expand Down Expand Up @@ -128,6 +132,7 @@ public struct VideoMetadata: Sendable {
self.streamOrder = []
self.timecodes = []
self.chapters = []
self.attachments = []
self.cameraTimeline = []
}

Expand Down
20 changes: 20 additions & 0 deletions Sources/SwiftMediaMetadata/API/VideoStream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,26 @@ public struct DataStream: Sendable, Equatable {
}
}

/// A file attached to the container (Matroska `AttachedFile`), such as a font
/// used by ASS/SSA subtitles. `index` is the position among attachments and
/// matches ffmpeg's `t:<index>` stream specifier.
public struct AttachmentStream: Sendable, Equatable {
public var index: Int
public var fileName: String
public var mimeType: String
public var fileDescription: String?
/// Size of the attached payload in bytes.
public var size: Int
public var uid: UInt64?

public init(index: Int, fileName: String, mimeType: String, size: Int) {
self.index = index
self.fileName = fileName
self.mimeType = mimeType
self.size = size
}
}

/// Discriminated locator for a per-track stream in `VideoMetadata.streamOrder`.
/// The associated value is the index into the matching typed array.
public enum StreamKind: Sendable, Equatable {
Expand Down
80 changes: 79 additions & 1 deletion Sources/SwiftMediaMetadata/Video/MatroskaReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public struct MatroskaReader: Sendable {
var tagsBlocks: [(start: Int, end: Int)] = []
var clusterBlocks: [(start: Int, end: Int)] = []
var chaptersBlocks: [(start: Int, end: Int)] = []
var attachmentsBlocks: [(start: Int, end: Int)] = []

while reader.offset < end, reader.remainingCount >= 2 {
guard let id = try? readEBMLID(&reader),
Expand Down Expand Up @@ -127,7 +128,9 @@ public struct MatroskaReader: Sendable {
}
case 0x1043A770: // Chapters (top-level in Segment)
chaptersBlocks.append((childStart, childEnd))
case 0x1C53BB6B, 0x1941A469: // Cues, Attachments
case 0x1941A469: // Attachments
attachmentsBlocks.append((childStart, childEnd))
case 0x1C53BB6B: // Cues
break
default:
break
Expand All @@ -148,6 +151,10 @@ public struct MatroskaReader: Sendable {
parseChapters(data, from: s, end: e, into: &metadata)
}

for (s, e) in attachmentsBlocks {
parseAttachments(data, from: s, end: e, into: &metadata)
}

// Clear stale shared BPS / NUMBER_OF_FRAMES tags BEFORE the cluster
// walker runs — otherwise every audio track looks like it already has
// a bit rate and the walker skips them. The walker only fills gaps.
Expand Down Expand Up @@ -1692,6 +1699,77 @@ public struct MatroskaReader: Sendable {
}
}

/// Attachments master (0x1941A469) > AttachedFile (0x61A7). Entries
/// missing a name, MIME type or payload are skipped, as ffmpeg does, so
/// `index` stays aligned with ffmpeg's attachment stream numbering.
private static func parseAttachments(
_ data: Data,
from start: Int,
end: Int,
into metadata: inout VideoMetadata
) {
var reader = BinaryReader(data: data)
(try? reader.seek(to: start)) ?? ()

while reader.offset < end {
guard let id = try? readEBMLID(&reader),
let size = try? readVINT(&reader),
reader.offset + Int(size) <= end else { break }
let childStart = reader.offset
let childEnd = childStart + Int(size)
if id == 0x61A7 { // AttachedFile
parseAttachedFile(data, from: childStart, end: childEnd, into: &metadata)
}
if (try? reader.seek(to: childEnd)) == nil { return }
}
}

private static func parseAttachedFile(
_ data: Data,
from start: Int,
end: Int,
into metadata: inout VideoMetadata
) {
var reader = BinaryReader(data: data)
(try? reader.seek(to: start)) ?? ()

var fileName: String?
var mimeType: String?
var fileDescription: String?
var payloadSize = 0
var uid: UInt64?

while reader.offset < end {
guard let id = try? readEBMLID(&reader),
let size = try? readVINT(&reader),
reader.offset + Int(size) <= end else { break }
let s = reader.offset
let sz = Int(size)
switch id {
case 0x466E: fileName = readStringPayload(data, offset: s, size: sz) // FileName
case 0x4660: mimeType = readStringPayload(data, offset: s, size: sz) // FileMimeType
case 0x467E: fileDescription = readStringPayload(data, offset: s, size: sz) // FileDescription
case 0x465C: payloadSize = sz // FileData
case 0x46AE: uid = readUIntPayload(data, offset: s, size: sz) // FileUID
default: break
}
if (try? reader.seek(to: s + sz)) == nil { break }
}

guard let fileName, !fileName.isEmpty,
let mimeType, !mimeType.isEmpty,
payloadSize > 0 else { return }
var attachment = AttachmentStream(
index: metadata.attachments.count,
fileName: fileName,
mimeType: mimeType,
size: payloadSize
)
attachment.fileDescription = fileDescription
attachment.uid = uid
metadata.attachments.append(attachment)
}

private static func parseEditionEntry(
_ data: Data,
from start: Int,
Expand Down
36 changes: 36 additions & 0 deletions Tests/SwiftMediaMetadataTests/Video/MatroskaReaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,42 @@ final class MatroskaReaderTests: XCTestCase {
XCTAssertNil(m.chapters[2].endTime)
}

/// Matroska `Attachments` > `AttachedFile` — fonts shipped for ASS
/// subtitles. An entry without a MIME type is skipped (as ffmpeg does) so
/// indices stay aligned with ffmpeg's `t:<n>` attachment streams.
func testMKVAttachmentsDecode() throws {
func attachedFile(name: String, mime: String?, description: String?, uid: UInt64, bytes: Int) -> Data {
var payload = Data()
if let description {
payload.append(ebmlElement(id: [0x46, 0x7E], payload: Data(description.utf8))) // FileDescription
}
payload.append(ebmlElement(id: [0x46, 0x6E], payload: Data(name.utf8))) // FileName
if let mime {
payload.append(ebmlElement(id: [0x46, 0x60], payload: Data(mime.utf8))) // FileMimeType
}
payload.append(ebmlElement(id: [0x46, 0x5C], payload: Data(repeating: 0xAB, count: bytes))) // FileData
payload.append(ebmlElement(id: [0x46, 0xAE], payload: encodeUInt(uid))) // FileUID
return ebmlElement(id: [0x61, 0xA7], payload: payload) // AttachedFile
}
var ebmlPayload = Data()
ebmlPayload.append(ebmlElement(id: [0x42, 0x82], payload: Data("matroska".utf8))) // DocType
let ebmlHeader = ebmlElement(id: [0x1A, 0x45, 0xDF, 0xA3], payload: ebmlPayload)
let files = attachedFile(name: "Karaoke.ttf", mime: "font/ttf", description: "Lyrics", uid: 7, bytes: 32)
+ attachedFile(name: "Broken.otf", mime: nil, description: nil, uid: 8, bytes: 16)
+ attachedFile(name: "Signs.otf", mime: "application/vnd.ms-opentype", description: nil, uid: 9, bytes: 12)
let attachments = ebmlElement(id: [0x19, 0x41, 0xA4, 0x69], payload: files) // Attachments
let segment = ebmlElement(id: [0x18, 0x53, 0x80, 0x67], payload: attachments) // Segment

let m = try VideoMetadata.read(from: ebmlHeader + segment)
XCTAssertEqual(m.attachments.map(\.index), [0, 1])
XCTAssertEqual(m.attachments.map(\.fileName), ["Karaoke.ttf", "Signs.otf"])
XCTAssertEqual(m.attachments.map(\.mimeType), ["font/ttf", "application/vnd.ms-opentype"])
XCTAssertEqual(m.attachments.map(\.size), [32, 12])
XCTAssertEqual(m.attachments[0].fileDescription, "Lyrics")
XCTAssertEqual(m.attachments[0].uid, 7)
XCTAssertNil(m.attachments[1].fileDescription)
}

/// A hidden chapter atom (ChapterFlagHidden=1) is dropped on the floor —
/// matches ffprobe's behaviour. The remaining chapters are re-indexed so
/// their `index` field is contiguous.
Expand Down
Loading