From 3f2f24c659b8e3e9c82d3dac1cfe871477309c5c Mon Sep 17 00:00:00 2001 From: Truls Aagedal <5827165+aagedal@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:29:50 +0100 Subject: [PATCH 1/2] Expose Matroska attachments in video metadata Fansub MKVs ship the fonts used by ASS subtitles as AttachedFile elements. Parse them so callers can preserve and address the attachments (ffmpeg t: streams) when remuxing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++ .../API/VideoMetadata.swift | 5 ++ .../SwiftMediaMetadata/API/VideoStream.swift | 20 +++++ .../Video/MatroskaReader.swift | 80 ++++++++++++++++++- .../Video/MatroskaReaderTests.swift | 36 +++++++++ 5 files changed, 149 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb8d66..3c6d216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## [Unreleased] + +### 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:` specifiers. + ## [3.0.3] — 2026-10-22 ### Fixed diff --git a/Sources/SwiftMediaMetadata/API/VideoMetadata.swift b/Sources/SwiftMediaMetadata/API/VideoMetadata.swift index 84bef50..d92946b 100644 --- a/Sources/SwiftMediaMetadata/API/VideoMetadata.swift +++ b/Sources/SwiftMediaMetadata/API/VideoMetadata.swift @@ -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? @@ -128,6 +132,7 @@ public struct VideoMetadata: Sendable { self.streamOrder = [] self.timecodes = [] self.chapters = [] + self.attachments = [] self.cameraTimeline = [] } diff --git a/Sources/SwiftMediaMetadata/API/VideoStream.swift b/Sources/SwiftMediaMetadata/API/VideoStream.swift index 2a20981..5a23205 100644 --- a/Sources/SwiftMediaMetadata/API/VideoStream.swift +++ b/Sources/SwiftMediaMetadata/API/VideoStream.swift @@ -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:` 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 { diff --git a/Sources/SwiftMediaMetadata/Video/MatroskaReader.swift b/Sources/SwiftMediaMetadata/Video/MatroskaReader.swift index dd5f8a5..4c407ba 100644 --- a/Sources/SwiftMediaMetadata/Video/MatroskaReader.swift +++ b/Sources/SwiftMediaMetadata/Video/MatroskaReader.swift @@ -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), @@ -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 @@ -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. @@ -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, diff --git a/Tests/SwiftMediaMetadataTests/Video/MatroskaReaderTests.swift b/Tests/SwiftMediaMetadataTests/Video/MatroskaReaderTests.swift index ae76914..f4b5979 100644 --- a/Tests/SwiftMediaMetadataTests/Video/MatroskaReaderTests.swift +++ b/Tests/SwiftMediaMetadataTests/Video/MatroskaReaderTests.swift @@ -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:` 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. From 849de049af03735e14a94ac2a327159463029a1d Mon Sep 17 00:00:00 2001 From: Truls Aagedal <5827165+aagedal@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:46:55 +0100 Subject: [PATCH 2/2] Prepare SwiftMediaMetadata 3.1.0 release Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 8 ++++---- RELEASE_CHECKLIST.md | 15 +++++++-------- Sources/CLI/SwiftMediaMetadata.swift | 2 +- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c6d216..0d039ec 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.1.0] — 2026-10-30 ### Added diff --git a/README.md b/README.md index 5a4ae3e..2da6df5 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.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 @@ -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"), ] ``` diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 7e53648..46504ac 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -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:` 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. diff --git a/Sources/CLI/SwiftMediaMetadata.swift b/Sources/CLI/SwiftMediaMetadata.swift index c658379..d273bdc 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.3", + version: "3.1.0", subcommands: [ ReadCommand.self, WriteCommand.self,