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

### Fixed

- ProRes 4444 / 4444 XQ (`ap4h` / `ap4x`) in MOV/MP4 no longer always report
`pixelFormat = "yuva444p12le"`. Alpha is optional in 4444, and most camera
and grading masters are opaque. The VisualSampleEntry depth now decides it:
32 means alpha (`yuva444p12le`), 24 means opaque (`yuv444p12le`). For any
other depth the first frame header's `alpha_channel_type` decides, as in
ffprobe; this is a 26-byte read from the mapped file. Without either signal
the stream is reported opaque. `hasAlphaChannel` is now set for these
streams as well.
- The sample-entry depth is no longer read as `bitDepth` for ProRes, so
4444 stays at 12 bits.
- FourCC-derived profile, chroma and bit-depth defaults (ProRes, ProRes RAW,
APV) now apply to sample entries that have no child boxes.

## [3.1.0] — 2026-10-30

### Added
Expand Down
11 changes: 6 additions & 5 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.1.0 release](https://github.com/aagedal/SwiftMediaMetadata/releases/tag/3.1.0):
[3.1.1 release](https://github.com/aagedal/SwiftMediaMetadata/releases/tag/3.1.1):

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

Expand Down Expand Up @@ -475,7 +475,8 @@ plus convenience accessors that mirror the first video stream at the top level
| `bitDepth` | 8 / 10 / 12 from `hvcC` / `av1C` / CDCI / Matroska `BitsPerChannel` or `CodecPrivate` |
| `chromaSubsampling` | `"4:2:0"`, `"4:2:2"`, `"4:4:4"`, `"4:0:0"`, `"4:1:1"` |
| `chromaLocation` | `"left"`, `"center"`, `"topleft"`, `"top"`, `"bottomleft"`, `"bottom"` — matches ffprobe `chroma_location` |
| `pixelFormat` | ffprobe-style `pix_fmt` string — `"yuv420p"`, `"yuv420p10le"`, `"yuvj420p"`, `"yuv444p12le"`, `"gray10le"`, derived from codec + chroma + depth + range |
| `pixelFormat` | ffprobe-style `pix_fmt` string — `"yuv420p"`, `"yuv420p10le"`, `"yuvj420p"`, `"yuv444p12le"`, `"gray10le"`, derived from codec + chroma + depth + range. ProRes 4444 / 4444 XQ report `"yuva444p12le"` only when the stream carries alpha (sample-entry depth 32, or a non-zero frame-header `alpha_channel_type`), otherwise `"yuv444p12le"` |
| `hasAlphaChannel` | `true` when alpha is signalled (HEVC `alpha_channel_info` SEI, ProRes 4444 with alpha); `false` for ProRes 4444 known to be opaque; `nil` when nothing signals it |
| `frameRate` | fps — from `stsz`/`stts` for ISOBMFF, Matroska `DefaultDuration`, MXF `SampleRate`, AVI `dwRate`/`dwScale`, MPEG-2 sequence header |
| `avgFrameRate` / `rFrameRate` | ffprobe-compatible pair — `avg_frame_rate` (average) and `r_frame_rate` (raw cadence) |
| `duration` | Per-track duration in seconds |
Expand Down
12 changes: 6 additions & 6 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# SwiftMediaMetadata 3.1.0 release checklist
# SwiftMediaMetadata 3.1.1 release checklist

- [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] ProRes 4444 / 4444 XQ alpha follows the sample-entry depth (32 alpha,
24 opaque), falling back to the first frame header's alpha_channel_type.
- [x] Verified against ffmpeg-encoded `ap4h` / `ap4x` MOVs with and without
alpha, including copies with the depth zeroed: `pixelFormat` matches ffprobe.
- [x] Set CLI version, README and changelog to 3.1.1.
- [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.
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.1.0",
version: "3.1.1",
subcommands: [
ReadCommand.self,
WriteCommand.self,
Expand Down
6 changes: 4 additions & 2 deletions Sources/SwiftMediaMetadata/API/VideoStream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,10 @@ public struct VideoStream: Sendable, Equatable {
/// captions (detected from H.264/HEVC SEI A/53 user-data wrappers, or
/// from MP4 `\u{a9}cca` / Matroska CodecPrivate hints).
public var hasClosedCaptions: Bool?
/// True when the stream signals an alpha (transparency) channel via the
/// HEVC `alpha_channel_info` SEI message.
/// True when the stream signals an alpha (transparency) channel: the
/// HEVC `alpha_channel_info` SEI message, or ProRes 4444 / 4444 XQ with a
/// 32-bit sample-entry depth or a non-zero frame-header alpha_channel_type.
/// False for ProRes 4444 known to be opaque; nil when nothing signals it.
public var hasAlphaChannel: Bool?

public init(index: Int) {
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftMediaMetadata/Video/MP4Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public struct MP4Parser: Sendable {
trak.data,
movieTimescale: movieTimescale,
chapterTrackIDs: chapterReferencedTrackIDs,
fullData: data,
into: &metadata
)
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/SwiftMediaMetadata/Video/MP4TrackParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ extension MP4Parser {
_ data: Data,
movieTimescale: UInt32,
chapterTrackIDs: Set<UInt32> = [],
fullData: Data? = nil,
into metadata: inout VideoMetadata
) {
guard let children = try? ISOBMFFBoxReader.parseBoxes(from: data) else { return }
Expand Down Expand Up @@ -174,6 +175,11 @@ extension MP4Parser {
stream.isDefault = tkhdIsDefault
stream.rotation = tkhdRotation
parseVisualSampleEntry(stsdBox.data, into: &stream)
if let fullData {
applyProRes4444FrameHeaderAlpha(
stblChildren: stblChildren, fullData: fullData, into: &stream
)
}
// Blackmagic RAW: pull the three uint32 codec-config atoms
// (bfdn / ctrn / bver) out of the BRAW sample entry's child
// boxes and surface them next to the moov.meta clip slate.
Expand Down
111 changes: 92 additions & 19 deletions Sources/SwiftMediaMetadata/Video/MP4VisualSampleEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,15 @@ extension MP4Parser {
// Most codecs advertise 0x18 (24 = packed RGB 8-bit) here; higher-bit-depth
// values are rare and unreliable — the codec-specific config box (hvcC,
// av1C, prores atom …) is the real source of truth, parsed below.
// ProRes uses the field as a pixel depth (24 = opaque, 32 = alpha), not
// bits per component, so it never feeds bitDepth there.
var sampleEntryDepth: Int?
if stsdData.count >= payloadBase + 76 {
if (try? reader.seek(to: payloadBase + 74)) != nil,
let depth = try? reader.readUInt16BigEndian() {
if depth > 0, depth != 0x18, depth % 3 == 0, depth <= 96 {
sampleEntryDepth = Int(depth)
if !isProResFourCC(codec),
depth > 0, depth != 0x18, depth % 3 == 0, depth <= 96 {
stream.bitDepth = Int(depth / 3)
}
}
Expand All @@ -72,21 +77,40 @@ extension MP4Parser {
// start 8 bytes after the fixed VisualSampleEntry header at payloadBase + 78.
let entryEnd = entryStart + Int(entrySize32)
let childrenStart = payloadBase + 78
guard childrenStart < entryEnd, entryEnd <= stsdData.count else { return }

let childrenData = Data(
stsdData[(stsdData.startIndex + childrenStart) ..< (stsdData.startIndex + entryEnd)]
)
if let kids = try? ISOBMFFBoxReader.parseBoxes(from: childrenData) {
for kid in kids {
applyVisualChildBox(kid, into: &stream)
if childrenStart < entryEnd, entryEnd <= stsdData.count {
let childrenData = Data(
stsdData[(stsdData.startIndex + childrenStart) ..< (stsdData.startIndex + entryEnd)]
)
if let kids = try? ISOBMFFBoxReader.parseBoxes(from: childrenData) {
for kid in kids {
applyVisualChildBox(kid, into: &stream)
}
}
}

// Sample entries whose FourCC uniquely identifies the codec variant
// (ProRes, APV, ProRes RAW) can skip frame-header parsing entirely —
// fill in profile/chroma/bit_depth from the FourCC.
applyFourCCDefaults(codec, into: &stream)
applyFourCCDefaults(codec, sampleEntryDepth: sampleEntryDepth, into: &stream)
}

static func isProResFourCC(_ codec: String) -> Bool {
switch codec {
case "apco", "apcs", "apcn", "apch", "ap4h", "ap4x": return true
default: return false
}
}

/// ProRes 4444 / 4444 XQ: alpha is optional. QuickTime writers signal it
/// through the VisualSampleEntry depth (32 = alpha, 24 = opaque). Any
/// other value is inconclusive — `MP4Parser.applyProRes4444FrameHeaderAlpha`
/// then checks the first frame header, the field ffprobe decides from.
static func proRes4444HasAlpha(sampleEntryDepth depth: Int?) -> Bool? {
switch depth {
case 32: return true
case 24: return false
default: return nil
}
}

static func applyVisualChildBox(_ box: ISOBMFFBox, into stream: inout VideoStream) {
Expand Down Expand Up @@ -200,12 +224,60 @@ extension MP4Parser {
}
}

/// Fallback for ProRes 4444 / 4444 XQ tracks whose sample-entry depth
/// didn't settle alpha: read the first frame header (26 bytes at the first
/// chunk offset — the file is memory-mapped, so this touches one page).
/// Frame layout (SMPTE RDD 36): frame_size(4) + 'icpf'(4), then the frame
/// header — header_size(2) … alpha_channel_type in the low nibble of
/// header byte 17. Non-zero means alpha, as in ffmpeg's decoder.
static func applyProRes4444FrameHeaderAlpha(
stblChildren: [ISOBMFFBox],
fullData: Data,
into stream: inout VideoStream
) {
guard stream.codec == "ap4h" || stream.codec == "ap4x",
stream.hasAlphaChannel == nil else { return }
let chunkOffset: UInt64? = {
if let co64 = stblChildren.first(where: { $0.type == "co64" }) {
return parseCO64First(co64.data)
}
if let stco = stblChildren.first(where: { $0.type == "stco" }) {
return parseSTCOFirst(stco.data).map(UInt64.init)
}
return nil
}()
guard let offset = chunkOffset,
let hasAlpha = proResFrameHeaderHasAlpha(at: offset, in: fullData) else { return }
stream.hasAlphaChannel = hasAlpha
// Only replace the opaque default applyFourCCDefaults chose.
if hasAlpha, stream.pixelFormat == "yuv444p12le" {
stream.pixelFormat = "yuva444p12le"
}
}

static func proResFrameHeaderHasAlpha(at offset: UInt64, in fullData: Data) -> Bool? {
// Bound in UInt64 space first: a crafted co64 value past Int.max
// would trap in `Int(offset)`.
guard offset <= UInt64(Int.max) else { return nil }
let s = Int(offset)
guard s <= fullData.count - 26 else { return nil }
let b = fullData.startIndex + s
guard fullData[(b + 4) ..< (b + 8)].elementsEqual("icpf".utf8) else { return nil }
let headerSize = (Int(fullData[b + 8]) << 8) | Int(fullData[b + 9])
guard headerSize >= 18 else { return nil }
return fullData[b + 8 + 17] & 0x0F != 0
}

/// Deterministic pix_fmt / profile / bit_depth for codecs whose sample
/// entry FourCC uniquely identifies the subcodec — notably ProRes, APV
/// and ProRes RAW. ffprobe reads the same fields from the frame header,
/// but every FourCC here maps 1:1 so surfacing them from the container
/// side matches ffprobe's output without any bitstream parsing.
static func applyFourCCDefaults(_ codec: String, into stream: inout VideoStream) {
static func applyFourCCDefaults(
_ codec: String,
sampleEntryDepth: Int? = nil,
into stream: inout VideoStream
) {
switch codec {
// Apple ProRes (SMPTE RP 2019)
case "apco": // 422 Proxy
Expand All @@ -224,16 +296,17 @@ extension MP4Parser {
stream.profile = stream.profile ?? "HQ"
stream.chromaSubsampling = stream.chromaSubsampling ?? "4:2:2"
stream.bitDepth = stream.bitDepth ?? 10
case "ap4h": // 4444 — always carries alpha, 12-bit YUV
stream.profile = stream.profile ?? "4444"
stream.chromaSubsampling = stream.chromaSubsampling ?? "4:4:4"
stream.bitDepth = stream.bitDepth ?? 12
stream.pixelFormat = stream.pixelFormat ?? "yuva444p12le"
case "ap4x": // 4444 XQ
stream.profile = stream.profile ?? "4444XQ"
case "ap4h", "ap4x": // 4444 / 4444 XQ — 12-bit YUV, alpha optional
stream.profile = stream.profile ?? (codec == "ap4h" ? "4444" : "4444XQ")
stream.chromaSubsampling = stream.chromaSubsampling ?? "4:4:4"
stream.bitDepth = stream.bitDepth ?? 12
stream.pixelFormat = stream.pixelFormat ?? "yuva444p12le"
// Most camera and grading masters are opaque, so an inconclusive
// depth falls back to the opaque format (as ffprobe does when the
// frame header's alpha_channel_type is 0).
let hasAlpha = proRes4444HasAlpha(sampleEntryDepth: sampleEntryDepth)
stream.hasAlphaChannel = stream.hasAlphaChannel ?? hasAlpha
stream.pixelFormat = stream.pixelFormat
?? (hasAlpha == true ? "yuva444p12le" : "yuv444p12le")
// Apple ProRes RAW
case "aprn":
stream.profile = stream.profile ?? "RAW"
Expand Down
Loading
Loading