A native Swift library for reading and writing image and video metadata — Exif, IPTC (IIM), XMP, C2PA, MakerNotes, and ICC profiles — with no external dependencies.
| Format | Read | Write | Metadata Types |
|---|---|---|---|
| JPEG | Yes | Yes | Exif, IPTC, XMP, C2PA, ICC |
| TIFF | Yes | Yes | Exif, IPTC, XMP, C2PA, ICC |
| DNG / GPR / CR3 | Yes | Yes | Exif, IPTC, XMP, MakerNotes, ICC |
| Proprietary RAW (CR2, NEF, NRW, ARW, RAF, RW2, ORF, PEF, SRW, .raw, IIQ, 3FR, FFF, X3F, MRW) | Yes | XMP sidecar | Exif, IPTC, XMP, MakerNotes, ICC; embedded writes are refused by default because a full TIFF rewrite can damage maker-private data |
| JPEG XL (container) | Yes | Yes | Exif, XMP, C2PA, ICC |
| PNG | Yes | Yes | Exif, XMP, C2PA, ICC |
| AVIF | Yes | Yes | Exif, XMP, C2PA, ICC |
| HEIF / HEIC | Yes | Yes | Exif, XMP, C2PA, ICC |
| WebP | Yes | Yes | Exif, XMP, C2PA, ICC |
| GIF | Yes | Yes | XMP; C2PA read-only |
| Yes | Yes | XMP, document metadata; C2PA read-only | |
| PSD (Photoshop) | Yes | Yes | Exif, IPTC, XMP, ICC |
| BMP | Yes | — | Container and image dimensions; no embedded metadata writer |
| SVG | Yes | Yes | XMP |
| MP4 / MOV / M4V | Yes | Yes | XMP, GPS, C2PA, Sony NRT camera metadata, full stream info (codec, profile, fps, field order, bit depth, chroma subsampling, pixel format, color primaries/transfer/matrix/range, pixel aspect ratio, bit rate) + audio (codec, sample rate, channels, channel layout, bit depth, bit rate) + subtitle tracks (tx3g, WebVTT, TTML, CEA-608/708) with language, QuickTime tmcd timecode |
| Blackmagic RAW (.braw) | Yes | — | Container metadata via QuickTime layout (no ftyp, tail-placed moov): resolution, project frame rate, audio, timecode, plus the moov.meta slate — camera make/model, firmware, viewing gamma/gamut, color science, compression ratio, shutter type, sensor capture FPS (off-speed), production slate (clip number / scene / take / reel / camera / environment / day-night), sensor area, crop / safe-area rectangles, LUT used, post-3DLUT mode, frameguide aspect ratio, gamut compression. Per-frame interpretation attributes (white point, tint, absolute ISO) live in proprietary bfdn/ctrn boxes and remain unparsed |
| RED RAW (.R3D) | Yes | — | Clip-header metadata from RED's own length-prefixed RED2/RED1 atom: resolution (from rdi), audio sample rate (from rda), original capture frame rate, plus the TLV slate — camera brain + sensor, body serial, lens model, firmware, ISO, color temperature (Kelvin), crop area (WxH+X+Y), record/playback timecodes, reel + take, video format ("8K 16:9"), quality preset, storage media + serial + format date/time, original camera filename, focus distance. Tag IDs match ExifTool's Image::ExifTool::Red table |
| Nikon RAW Video (N-RAW) | Yes | — | Detected by ftyp niko brand + NR3D codec FourCC. Nikon Z8/Z9 ship N-RAW with a .R3D extension as part of the post-acquisition "RED RAW" branding, but the bitstream is wholly unrelated to RED's REDCODE — promoted to format = .nikonRaw so callers can disambiguate. All standard MP4 metadata (resolution, timecode, audio, color) reads through the QuickTime path |
| MXF (SMPTE 377) | Yes | — | C2PA, Sony NonRealTimeMeta (RDD-18), picture/sound essence descriptors (resolution, frame rate, scan type, chroma, color) |
| Sony X-OCN (.mxf) | Yes | — | MXF wrapper detected via picture-essence UL (060e2b34.0401.0106.0e06.0401.0206.06xx) or NRT videoCodec label. Promotes format = .xocn with codec long-names Sony X-OCN LT/ST/XT. Deepens NRT AcquisitionRecord harvest: every CameraUnitMetadataSet, SonyF65CameraMetadataSet, CameraPostureMetadataSet, LensUnitMetadataSet <Item> is captured into a flat acquisitionGroups dictionary, with curated typed fields on CameraMetadata for ISO, exposure index, shutter angle/time, ND filter, white balance, capture/look gamma + color, raw black/gray/white code values, monitoring LUT, sensor effective size, camera tilt/roll. ASC CDL Slope/Offset/Power/Saturation from <ExtendedContents> parsed into ascCDL (identity transforms suppressed). Verified against Sony F55 8.6K 3:2 X-OCN LT clips |
| ARRIRAW (.mxf) | Yes | — | MXF-wrapped ARRIRAW detection, stream metadata, and ARRI camera JSON/slate metadata |
| Canon Cinema RAW Light (.CRM / .CRL) | Yes | — | ISOBMFF with ftyp crx and the same Canon-metadata UUID (85c0b687-820f-11e0-8111-f4ce462b6a48) used by CR3 still images. Distinguished from CR3 by CNCV "CanonCRM...". Reads the in-moov CMT1/CMT2/CMT3/CMT4 TIFF IFDs (Make/Model/Lens/exposure/GPS via the shared CanonUUIDExtractor) plus the dedicated CTMD track for per-frame timed metadata: type 0x0001 timestamp (with hundredths-of-a-second), 0x0004 focal length (mm), 0x0005 exposure (F-number / shutter / ISO), 0x0007/8/9 embedded TIFF blocks (CanonColorData → R/G1/G2/B white-balance multipliers). The full per-frame stream lands in videoMetadata.cameraTimeline; first-frame values populate single-valued CameraMetadata fields. THMB and PRVW JPEGs are surfaced as embeddedThumbnailJPEG / embeddedPreviewJPEG. .CRL proxies share the layout and tag as format = .crl ("Canon Cinema RAW Light Proxy"). Verified against EOS C70 master clips |
| Matroska (.mkv) | Yes | — | Stream info (codec, profile, fps, dimensions, bit depth, chroma, chroma location, color, pixel format) decoded from both Tracks and CodecPrivate (hvcC/av1C/avcC), Segment-level COMMENT/DESCRIPTION tags, audio tracks, subtitle tracks (SRT, ASS/SSA, WebVTT, PGS, VobSub) with language + default/forced/SDH flags |
| WebM (.webm) | Yes | — | Stream info (VP8/VP9/AV1) + audio (Vorbis/Opus) + subtitle tracks |
| AVI (RIFF) | Yes | — | Stream info (codec, fps, dimensions, bit depth) + audio (codec, sample rate, channels), INFO tags |
| MPEG-PS / MPEG-TS / M2TS | Yes | — | Sequence-header stream facts (resolution, fps, aspect, bit rate), PMT elementary-stream inventory (DVB subtitles / teletext / PGS with language), M2TS (Blu-ray BDAV, 192-byte packets) auto-detected |
| IVF | Yes | — | VP8, VP9, AV1, or AV2 elementary-stream codec, dimensions, frame rate, duration, and frame count |
| MP3 (ID3v1 / ID3v2) | Yes | Yes | Tags + codec, sample rate, channels, bit rate, duration; ID3v2 frame detail (TXXX/WXXX/PRIV/GEOB/CHAP/CTOC) |
| FLAC | Yes | Yes | Tags + sample rate, channels, bit depth, duration; SeekTable + CueSheet |
| M4A | Yes | Yes | Tags + codec, sample rate, channels, bit depth, channel layout, bit rate, duration |
| Ogg Opus (.opus) | Yes | — | Vorbis comments + channels, sample rate, channel layout, duration |
| Ogg Vorbis (.ogg / .oga) | Yes | — | Vorbis comments + channels, sample rate, bit rate, duration |
| WAV / BWF (RIFF) | Yes | Yes | LIST/INFO tags, Broadcast WAVE bext (v0/v1/v2), iXML, C2PA, sample rate, channels, bit depth |
| AIFF / AIFC | Yes | Yes | NAME/AUTH/(c)/ANNO/COMT chunks, COMM 80-bit sample rate, channels, bit depth |
| XMP sidecar (.xmp) | Yes | Yes | XMP |
| C2PA sidecar (.c2pa) | Yes | — | External JUMBF manifest store next to the asset |
| AAE sidecar (Apple Photos) | Yes | — | iPhone/iPad edit-decision sidecar |
| Sony NRT sidecar (.XML) | Yes | — | Camera metadata auto-probed next to MP4/MXF |
Compressed SVGZ payloads are not currently decoded; use an uncompressed SVG when reading or writing XMP.
- Swift 6.0+
- macOS 13+ / iOS 16+
Install the macOS arm64 CLI with Homebrew:
brew install aagedal/tap/swift-exif
swift-exif --versionOr download the archive and checksum directly from the 3.0.3 release:
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 --versionKeep the manually extracted executable and resource bundle together.
Add the package to your Package.swift with:
dependencies: [
.package(url: "https://github.com/aagedal/SwiftMediaMetadata.git", from: "3.0.3"),
]Then add it as a dependency to your target:
.target(name: "YourApp", dependencies: ["SwiftMediaMetadata"]),Version 2.0 renames the package, library product, and importable module from
SwiftExif to SwiftMediaMetadata. Update the package URL and target dependency
above, then replace import SwiftExif with import SwiftMediaMetadata. The
installed CLI command remains swift-exif.
For the APIs introduced in 3.0, including exhaustive-enum changes, synchronization policies, typed projections, transactional sidecars, semantic preservation, and GPS conversion, see the migration guide.
The supported release target. Builds with the system Swift toolchain (Xcode 16+ / Swift 6.0+):
./Scripts/build-release.shOutput lands at dist/swift-exif-macos-arm64.tar.gz. The archive contains the
stripped CLI, its adjacent SwiftPM resource bundle, which supplies the offline
geolocation database, and the project license. Keep the executable and bundle
together when installing manually.
For a plain development build, swift build -c release places the executable
and resource bundle together under .build/release.
Linux binaries are no longer part of the release pipeline. The recipe below is preserved for downstream contributors who need a static-musl build. It cross-compiles from macOS using the official swift.org toolchain (the macOS-bundled Swift inside Xcode is not compatible with the Static Linux SDK).
-
Install the swift.org toolchain via swiftly:
swiftly install 6.3.1 source "$HOME/.swiftly/env.sh"
-
Install the Static Linux SDK:
swift sdk install \ https://download.swift.org/swift-6.3.1-release/static-sdk/swift-6.3.1-RELEASE/swift-6.3.1-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz \ --checksum fac05271c1f7d060bd203240ce5251d5ca902d30ac899f553765dbb3a88b97ad
-
Cross-compile (replace
x86_64withaarch64for ARM64 Linux):SDK_ROOT="$HOME/Library/org.swift.swiftpm/swift-sdks/swift-6.3.1-RELEASE_static-linux-0.1.0.artifactbundle/swift-6.3.1-RELEASE_static-linux-0.1.0/swift-linux-musl/musl-1.2.5.sdk" swift build -c release \ --swift-sdk x86_64-swift-linux-musl \ --static-swift-stdlib \ --product swift-exif \ -Xswiftc -Onone \ -Xlinker "$SDK_ROOT/x86_64/usr/lib/libz.a" \ --disable-sandbox
Keep the generated
SwiftMediaMetadata_SwiftMediaMetadata.bundledirectory next to the executable when copying it out of.build; it contains the offline geolocation database.-Ononeis mandatory:-Owith whole-module-optimization stalls the swift-6.3.1 musl optimizer indefinitely (frontend pinned at ~100% CPU with no progress).-Ononecompiles in a few minutes; the binary is larger and slower than the macOS build but functional. -
Optional shrink — strip debug info and compress with UPX:
llvm-strip .build/x86_64-swift-linux-musl/release/swift-exif upx --best --no-progress .build/x86_64-swift-linux-musl/release/swift-exif
UPX reduces the binary from ~70 MB to ~25 MB at the cost of a few hundred milliseconds of one-time decompression at startup.
import SwiftMediaMetadata
// From a file URL
let metadata = try readMetadata(from: imageURL)
// From data in memory
let metadata = try readMetadata(from: imageData)let headline = metadata.iptc.value(for: .headline)
let keywords = metadata.iptc.values(for: .keywords)
let caption = metadata.iptc.value(for: .captionAbstract)if let exif = metadata.exif {
let camera = exif.value(for: .make)
let model = exif.value(for: .model)
}The same API writes every format marked “Yes” in the table above. Proprietary RAW files default to XMP sidecars because embedded TIFF rewrites are unsafe:
var metadata = try readMetadata(from: imageURL)
metadata.iptc.setValue("Breaking news photo", for: .headline)
metadata.iptc.setValue("Jane Doe", for: .byline)
metadata.iptc.setValues(["news", "politics"], for: .keywords)
try metadata.write(to: outputURL)Use the result API when a caller needs a uniform outcome across media types or must surface non-fatal warnings. Serialization is a separate, filesystem-free step:
let serialized = try metadata.serialized()
let bytes: Data = serialized.output
let written = try metadata.writeResult(to: outputURL)
print("Wrote \(written.output.path)")
for warning in written.warnings {
print("Warning: \(warning)")
}ImageMetadata, VideoMetadata, and AudioMetadata expose serialized() and
writeResult(to:options:); XMPSidecar exposes the equivalent static methods.
The earlier writeToData() and write(to:) methods remain supported.
Filesystem modification dates update by default, while creation dates are preserved by default. Either timestamp can be assigned independently:
// In-place edit
let preserve = ImageMetadata.WriteOptions(
fileModificationDate: .preserveExisting
)
try metadata.write(to: imageURL, options: preserve)
// Source → new destination
let values = try sourceURL.resourceValues(forKeys: [.contentModificationDateKey])
if let sourceDate = values.contentModificationDate {
let copyDate = ImageMetadata.WriteOptions(
fileModificationDate: .set(sourceDate),
fileCreationDate: .set(sourceDate)
)
try metadata.write(to: outputURL, options: copyDate)
}The CLI equivalent for in-place writes is -P or
--preserve-file-modification-date.
Read and write .xmp sidecar files alongside image files:
// Write XMP sidecar for a RAW file
var metadata = try readMetadata(from: rawFileURL)
metadata.syncIPTCToXMP()
try metadata.writeSidecar(for: rawFileURL) // creates IMG_001.xmp
// Read XMP sidecar
var xmp = try readXMPSidecar(for: rawFileURL)
print(xmp.headline)For concurrent editors, read a content revision and use a transactional mutation. The closure receives the latest parsed packet and preserves fields it does not change. It can run more than once when a competing writer commits, so keep it free of one-shot side effects:
let sidecarURL = XMPSidecar.sidecarURL(for: rawFileURL)
let snapshot = try XMPSidecar.readSnapshot(from: sidecarURL)
let result = try XMPSidecar.update(
at: sidecarURL,
expectedRevision: snapshot.revision,
maximumRetries: 2,
options: ImageMetadata.WriteOptions(
fileModificationDate: .preserveExisting
)
) { xmp in
xmp.rating = 5
}
print(result.revision)A base revision that was already stale throws
XMPSidecarUpdateError.staleRevision without overwriting the newer packet.
Use .missing as the expected revision when a transaction should create a
sidecar only if none exists.
Localized rdf:Alt values retain their original order, language tags,
duplicates, and empty entries:
let titles = xmp.languageAlternativeValue(
namespace: XMPNamespace.dc,
property: "title"
)
xmp.setValue(
.languageAlternative([
XMPLanguageAlternative(language: "x-default", value: "Default title"),
XMPLanguageAlternative(language: "nb-NO", value: "Norsk tittel"),
]),
namespace: XMPNamespace.dc,
property: "title"
)// Compatibility behavior: copy the legacy mappings directly
metadata.syncIPTCToXMP()
// Or the other way around
metadata.syncXMPToIPTC()
// Standards-aware merge: preserve localized dc:title and full-precision dates,
// carry explicit clear intent, and inspect pre-existing carrier disagreements.
let report = metadata.synchronizeIPTCToXMP(options: .init(
explicitlyClearedTags: [.headline]
))
print(report.conflicts)Use IPTCXMPSynchronizationOptions.replace when missing source values should
remove mapped destination values. The standards-aware API preserves dc:title
instead of treating it as IIM Object Name unless titlePolicy is explicitly
set to .mirrorObjectName.
For a carrier-independent view, photoMetadata prefers XMP, falls back to
IPTC-IIM and Exif, and retains all candidates so conflicts remain visible:
let photo = metadata.photoMetadata
print(photo.string(.headline) ?? "")
print(photo[.headline]?.source as Any)
print(photo.conflicts.keys)
let mutation = PhotoMetadataMutation(
values: [.keywords: .strings(["news", "portrait"])],
clearedFields: [.credit]
)
let mutationReport = try metadata.applyPhotoMetadataMutation(mutation)
print(mutationReport.unappliedFields)Each image format exposes an explicit read, write, and preservation contract
for Exif, IPTC-IIM, XMP, Camera Raw (crs:), ICC, and C2PA. This distinguishes
direct writes from RAW-sidecar workflows and opaque preservation:
let capabilities = metadata.metadataCapabilities
print(capabilities[.xmp].write) // directlyWritable or sidecarOnly
print(capabilities[.c2pa].preservation) // preservedOpaquelyUse semantic comparison after a write/read cycle or container conversion. It compares expanded XMP namespace names, normalized TIFF values, dates and GPS spellings, and canonical C2PA claims rather than raw container layout. The report provides facts; the application decides which differences are allowed:
let written = try metadata.writeToData()
let readBack = try ImageMetadata.read(from: written)
let preservation = metadata.preservationReport(comparedTo: readBack)
print(preservation.added)
print(preservation.removed)
print(preservation.changed)
print(preservation.unrepresentable)
print(preservation.opaquePreserved)Typed XMP GPS access retains each packet's original spelling while providing validated decimal values. Coordinate writers emit XMP's degrees/decimal-minutes form with six fractional minute digits by default; altitude and direction use reduced rationals with three fractional digits:
var xmp = metadata.xmp ?? XMPData()
try xmp.setGPSLatitude(59.913868)
try xmp.setGPSLongitude(-10.752245)
try xmp.setGPSAltitude(23.5)
try xmp.setGPSImageDirection(271.25, reference: .trueNorth)
let gps = try xmp.parsedGPS()
print(gps.latitude?.decimalDegrees as Any)
print(gps.latitude?.originalLexicalValue as Any)The parser also accepts decimal, directional decimal, degrees/minutes/seconds,
and degrees/decimal-minutes input. Invalid ranges and contradictory signs or
hemispheres throw XMPGPSParsingError. PhotoMetadata projects the typed XMP
and Exif GPS candidates together, so carrier disagreements remain visible.
Structured XMP setters retain unknown sibling fields. For app-specific
structures, use patchStructure or patchStructuredArrayItem with an
XMPStructurePatch to replace and remove only named members.
Read rich stream-level metadata from the video and cinema-camera containers in
the supported-format table above — enough to replace ffprobe in editorial
and media-pipeline tooling. Container essence (mdat / MXF KLV body / Matroska
clusters) is never fully materialised; parsers only touch the header metadata.
let video = try VideoMetadata.read(from: videoURL)Everything below is populated from the container header — no decoding, no AVFoundation, no external dependencies.
| Property | Type | Description |
|---|---|---|
format |
VideoFormat |
.mp4, .mov, .m4v, .mxf, .xocn, .mkv, .webm, .avi, .mpg, .braw, .arriraw, .r3d, .nikonRaw, .crm, .crl, .ivf |
formatLongName |
String? |
Human-readable container name ("QuickTime / MOV", "MP4 (MPEG-4 Part 14)", "Matroska", "WebM", …) — matches ffprobe format_long_name |
fileSize |
Int64? |
File size in bytes |
duration |
TimeInterval? |
Total playback duration in seconds |
creationDate |
Date? |
Capture / mux time (ISOBMFF mvhd, Matroska DateUTC, etc.) |
modificationDate |
Date? |
Embedded container modification time (for example QuickTime mvhd), not the filesystem modification date |
bitRate |
Int? |
Overall container bitrate in bits/second |
timecode |
String? |
Clip start timecode HH:MM:SS:FF (or HH:MM:SS;FF for drop-frame) — the first source the container yields |
timecodes |
[Timecode] |
Every timecode source the container carries, tagged with provenance — QuickTime tmcd track, moov>udta ©TIM, XMP xmpDM:startTimeCode/altTimeCode, MXF MaterialPackage vs FilePackage, Sony NRT LtcChangeTable. Mismatches trigger a timecode mismatch: entry in warnings |
chapters |
[VideoChapter] |
Chapter markers ordered by start time — QuickTime tref > chap text tracks and Nero udta > chpl in MP4/MOV, Chapters master in Matroska / WebM |
title / artist / comment |
String? |
QuickTime ©nam / ©ART / ©cmt, Matroska Info/Title + Segment-level COMMENT/DESCRIPTION, RIFF INFO |
gpsLatitude / gpsLongitude / gpsAltitude |
Double? |
QuickTime ©xyz / ISO 6709 |
c2pa |
C2PAData? |
Parsed C2PA manifest store (MP4/MOV uuid or top-level jumb, MXF SMPTE UL or Dark KLV) |
camera |
CameraMetadata? |
Sony NonRealTimeMeta (RDD-18) from MXF header or sidecar XML |
xmp |
XMPData? |
XMP packet embedded in uuid box or xml meta |
VideoMetadata.videoStreams: [VideoStream] exposes one entry per video track,
plus convenience accessors that mirror the first video stream at the top level
(videoWidth, videoHeight, videoCodec, frameRate, fieldOrder,
colorInfo, bitDepth, chromaSubsampling, pixelAspectRatio,
displayWidth, displayHeight).
VideoStream property |
Description |
|---|---|
index |
Track index within the container |
codec |
4CC / ID — "hvc1", "av01", "avc1", "apch", "V_VP9", "V_MPEGH/ISO/HEVC", … |
codecName |
Human-readable — "H.265 / HEVC", "Apple ProRes", "AV1", "VP9", "MPEG-2 Video" |
profile |
Codec profile — "Main", "Main 10", "High", "Main 4:4:4 12", "Professional" — from hvcC / av1C / avcC or Matroska CodecPrivate |
width / height |
Coded luma dimensions |
displayWidth / displayHeight |
Final rendered display dimensions, after PAR and rotation (matches ffprobe display_aspect_ratio). For an iPhone-recorded 3840×2160 portrait clip this is 2160×3840 with rotation = -90 — do not re-apply rotation when sizing UI |
pixelAspectRatio |
(Int, Int) — e.g. (40, 33) for anamorphic 1440×1080 |
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 |
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 |
frameCount |
Container-advertised frame count (ISOBMFF stsz, AVI dwLength, Matroska NUMBER_OF_FRAMES tag) |
fieldOrder |
.progressive, .topFieldFirst, .bottomFieldFirst, .mixed, .unknown |
colorInfo |
VideoColorInfo? — primaries / transfer / matrix / range (H.273 codes) with readable label |
hdr |
HDRMetadata? — SMPTE ST 2086 mastering display (R/G/B/W xy + max/min luminance), CTA-861.3 MaxCLL / MaxFALL, Dolby Vision config. Read from ISOBMFF mdcv / clli / dvcC / dvvC boxes, H.264 / H.265 SEI (payload types 137 and 144), and the Matroska MasteringMetadata (0x55D0) / MaxCLL (0x55BC) / MaxFALL (0x55BD) elements |
bitRate |
Per-stream bits/second (ISOBMFF btrt) |
timecode |
Per-stream timecode (when the track carries one) |
isAttachedPic |
true when the track is a cover-art / attached-picture track |
VideoColorInfo.label returns canonical names: "bt709", "bt601",
"bt2020", "bt2020-pq" (HDR10 / SMPTE ST 2084), "bt2020-hlg" (Hybrid
Log-Gamma), … — the same vocabulary ffprobe -show_streams uses.
Broadcast workflows frequently carry the same timecode value in several
independent places — a QuickTime tmcd track plus an XMP startTimeCode
plus an MXF Material/File TimecodeComponent — and the three can disagree
after a partial round-trip. VideoMetadata.timecodes keeps each one with
its provenance rather than merging them silently:
for tc in video.timecodes {
print(tc.source, tc.value, tc.frameRate ?? -1)
// tc.source is .tmcdTrack, .quicktimeUdta, .xmpDM, .xmpDMAlt,
// .mxfMaterialPackage, .mxfFilePackage, or .sonyNRT
}
if video.warnings.contains(where: { $0.hasPrefix("timecode mismatch:") }) {
// two or more sources disagree — worth surfacing to the operator
}The scalar timecode field stays in sync with the first recorded entry
for backward compatibility. --streams JSON output emits the full list at
format.Timecodes (array of { value, source, frameRate }) plus per-stream
Timecode fields when the source is track-local.
VideoMetadata.audioStreams: [AudioStream] plus top-level audioCodec,
audioSampleRate, audioChannels.
AudioStream property |
Description |
|---|---|
codec / codecName |
"mp4a" / "AAC", "ac-3" / "Dolby Digital (AC-3)", "A_OPUS" / "Opus", "lpcm" / "Linear PCM", "alac" / "ALAC", … |
profile |
Codec profile ("LC", "HE-AAC", "HE-AACv2", …) where the container carries one |
sampleRate |
Hz |
channels |
Channel count |
channelLayout |
"mono", "stereo", "stereo-headphones", "5.1", "7.1", … (from QuickTime chan box or synthesised from the channel count) |
bitDepth |
Bits per sample |
bitRate |
Bits/second (ISOBMFF btrt or MPEG-4 ES descriptor) |
duration |
Per-track duration |
language |
ISO 639-2/T code ("eng", "nor", "swe", …) |
isDefault |
Default-track flag if the container signals one |
QuickTime Sound Description V2 is handled correctly, so 24-bit LPCM and Float64-sample-rate ProRes/APV audio tracks report accurate channels and rate.
VideoMetadata.subtitleStreams: [SubtitleStream]:
for sub in video.subtitleStreams {
print(sub.codecName) // "SubRip (SRT)", "PGS (Blu-ray)", "3GPP Timed Text", "WebVTT"
print(sub.language) // "eng", "nor", "swe" — ISO 639-2/T
print(sub.title) // Matroska track name, when set
print(sub.isDefault) // Matroska FlagDefault
print(sub.isForced) // FlagForced — foreign-audio burn-in
print(sub.isHearingImpaired) // SDH flag
}Codec coverage:
- MP4/MOV/M4V:
tx3g(3GPP Timed Text),wvtt(WebVTT),stpp(TTML),c608/c708(CEA-608/708 closed captions),text(QuickTime Text); handler typessubt,text,sbtl,clcp. - Matroska / WebM:
S_TEXT/UTF8(SRT),S_TEXT/ASS,S_TEXT/SSA,S_TEXT/WEBVTT,S_HDMV/PGS(Blu-ray),S_VOBSUB,S_HDMV/TEXTST. - MPEG-TS: DVB subtitles (stream type
0x06+ descriptor0x59), DVB teletext (descriptor0x56), Blu-ray PGS (stream type0x82).
VideoMetadata.chapters: [VideoChapter] — start/end times in seconds from
presentation start, with optional title and language. Ordered by start time,
and re-indexed contiguously after hidden-atom suppression so chapter.index
always matches array position.
for ch in video.chapters {
print(ch.index, ch.startTime, ch.endTime ?? -1,
ch.title ?? "", ch.language ?? "")
// duration is a computed property: endTime - startTime (or nil)
if let d = ch.duration { print("lasts \(d)s") }
}VideoChapter property |
Description |
|---|---|
index |
Position in the chapter list (0-based, contiguous) |
id |
Stable identifier where the container provides one (Matroska ChapterUID); nil for MP4 chap / chpl |
startTime |
Seconds from presentation start |
endTime |
Seconds from presentation start; nil when the source doesn't record one (Nero chpl, open-ended Matroska atoms) |
duration |
Computed endTime − startTime; nil when endTime is nil |
title |
Chapter title; UTF-8 |
language |
BCP-47 or ISO 639-2/T, when the container records one (Matroska ChapLanguage / ChapLanguageBCP47) |
Source coverage:
- MP4 / MOV / M4V: QuickTime text-track chapters — any trak's
tref > chappoints at a text/subt trak whose stts-timed UTF-8 samples carry the titles (DaVinci Resolve, Apple Compressor, iTunes, ffmpeg-map_chapters). Falls back to Neroudta > chpl(x264, ffmpeg, MP4Box) when no chap reference exists. The chap-referenced text track is filtered out ofsubtitleStreamsto match ffprobe, which reclassifies those tracks ascodec_type=dataunder-select_streams s. - Matroska / WebM: top-level
Chaptersmaster — walks everyEditionEntry+ChapterAtom, honouringEditionFlagHiddenandChapterFlagHidden; supportsChapterDisplay > ChapString, bothChapLanguageand the newerChapLanguageBCP47.
-
MP4 / MOV / M4V: per-track
mdhdtimescale + language, visual sample entry walk (fiel,pasp,colrfornclx/nclc,hvcC,av1C,avcC,btrt) including codec profile extraction, QuickTimechanchannel layouts, V0/V1/V2 Sound Description. QuickTimetmcdtimecode tracks: frame counter read frommdatviastco/co64, formattedHH:MM:SS:FFwith SMPTE 12M drop-frame arithmetic. Also: embedded XMP (uuidBE7ACFCB-…), GPS (©xyz), C2PA manifests, and Sony NRT sidecar auto-discovery. -
Blackmagic RAW (
.braw): ISOBMFF derivative with the legacy QuickTime layout —wide+mdatat the head,moovtail-placed, noftyp. Standard boxes (mvhd,tkhd,stsd,stts,mdhd) yield duration, project frame rate (e.g. 24p), resolution, audio, and timecode. The Blackmagic slate is parsed out ofmoov.metausing QuickTime's non-FullBoxmdtalayout — a sniff at the box header disambiguates the ISOBMFF/iTunes FullBox shape from the QuickTime shape so existing MP4 / iTunes parsing stays untouched. Themdtadecoder handles BRAW's typed payloads, including the BMD-specific type 71 (float32 BE pair) used for rectangle fields likesensor_area_capturedand the BMD type 77 (uint32) used forbraw_codec_bitrate. Surfaces toCameraMetadata:- Camera + lens — make / model, firmware, lens model, color science
generation, viewing gamma / gamut, shutter type, compression ratio,
analog_gainand_is_constant, off-speed flag. - Production slate — clip number, scene, take, reel, camera number, operator, director, production name, environment, day/night, location, filters, frameguide aspect, anamorphic flag.
- Sensor / framing —
sensor_area_captured,crop_origin/crop_size/safe_area,sensor_line_time(rolling-shutter μs),sensor_photosite_pitch_in_micrometres,rotation,time_lapse_interval. - Lens corrections + OIS —
lens_shading_enable,lens_distortion_correction_enable,lens_chromatic_aberration_correction_enable,ois_enable. - Tone curve — contrast, saturation, midpoint, highlights, shadows,
black/white level,
video_black_level,highlight_recovery,gamut_compression_enable. - Embedded 3D LUT —
post_3dlut_mode,_embedded_name,_embedded_title,_embedded_bmd_gamma,_embedded_size(cube edge), and a_embedded_datasize marker (the ~432 KB blob is not inlined). - BRAW codec config —
braw_codec_bfdn,braw_codec_ctrn,braw_codec_bver, andbraw_codec_bitrateare pulled from the BRAW sample entry's child boxes; works across the BRAW quality presets (brhqHigh Quality,brstStandard,brltLight, …). - Per-frame motion tracks —
mebxtracks declaring thecom.blackmagicdesign.motiondata.gyroscope/…accelerometernamespaces are flagged withhas_gyroscope_motion_data/has_accelerometer_motion_data. The per-frame vec3 samples themselves are not decoded. - First-frame interpretation — frame 0's
bmdfheader in mdat yieldsshutter_angle(shtvatom, UTF-8 padded — e.g. "180°"),aperture(aptr— e.g. "f2.7"),focal_length(fcln— e.g. "135mm"),focus_distance(dsnc— e.g. "2430mm"),iso(isoe),white_balance_kelvin(wkel), andwhite_balance_tint(wtin, signed int16). The per-frame header carries identical values across every frame in the clips we've tested, so frame 0 is sufficient for the clip-level default; we don't iterate over the rest. The four lens strings are skipped when empty (which the camera emits on bodies without electronic lens contacts). Other atoms in the same header (srte,agpf,expo,shdp,dcp[ugrb], …) carry per-frame state we haven't yet mapped.
Off-speed shoots populate
captureFpsdistinct fromframeRate— a 24p clip captured at 112 fps reportsframeRate=24andcaptureFps≈112.Per-frame export —
swift-exif braw-frames <file.braw>walks every frame'sbmdfheader (or everymebxIMU sample) and emits CSV for graphing. Three streams:attributes(default; one row per video frame with shutter / aperture / focal length / focus distance / ISO / WB Kelvin / tint),gyroscope(rad/s vec3 at ~1 kHz), andaccelerometer(m/s² vec3, gravity observable on the up-axis). Numeric columns; opens directly in Excel / pandas / matplotlib / gnuplot. Public Swift API:BRAWFrameReader.readAttributes(from:)andBRAWFrameReader.readMotionSamples(from:stream:). - Camera + lens — make / model, firmware, lens model, color science
generation, viewing gamma / gamut, shutter type, compression ratio,
-
RED RAW (
.R3D): not ISOBMFF — RED uses its own length-prefixed container with a fixed 1202-byteRED2(orRED1, on older firmware) clip-header atom carrying every metadata field. The header packs three fixed-layout sub-atoms (rdifor image dimensions,rdafor audio sample rate, twordxmarkers) followed by a stream of TLV records. Each TLV is[1-byte length][2-byte tag = class<<8 | id][value]where the tag IDs match ExifTool'sImage::ExifTool::Redtable —0x1006SerialNumber,0x101aReelNumber,0x101bTake,0x1023DateCreated,0x1024TimeCreated,0x1025FirmwareVersion,0x1029ReelTimecode,0x102aStorageType,0x1056OriginalFileName,0x1070LensModel,0x1086VideoFormat ("8K 16:9"),0x10a0Brain,0x10a1Sensor,0x10beQuality,0x200dColorTemperature (float32 BE Kelvin),0x2066OriginalFrameRate (float32),0x4037CropArea (4× uint16 origin/dims),0x403bISO (uint16),0x606cFocusDistance (uint16 mm). Two non-ExifTool timecode strings (0x10ad,0x10ae) surface as additionalTimecodeentries tagged.redR3D. Records exceeding 64 bytes are skipped as a sanity cap (largest real TLV is the 28-byte original-filename string).camera.deviceManufactureris set unconditionally to"RED"since the format doesn't include it as a TLV. Tested on KOMODO-X (RD4.15 firmware) and V-RAPTOR [X] (RD4.15) clips. -
MXF (SMPTE 377-1): picture and sound essence descriptors parsed from header metadata —
StoredWidth/StoredHeight,DisplayWidth/DisplayHeight,FrameLayout(scan type),ComponentDepth,HorizontalSubsampling/VerticalSubsampling,SampleRate(frame rate),ContainerDuration, colour ULs → H.273 codes. KLV essence is skipped by seek, so gigabyte files parse cheaply. -
Matroska / WebM: EBML/VINT walker over Segment
Info+Tracks+Tags. Colour master element (primaries / transfer / matrix / range) +ChromaSubsamplingHorz/Vert+ChromaSitingHorz/Vert→chroma_location,BitsPerChannel,DefaultDuration→ fps,FlagInterlaced+FieldOrder, subtitleFlagDefault/FlagForced/FlagHearingImpaired.CodecPrivateis decoded for HEVC / AV1 / AVC tracks (same layout ashvcC/av1C/avcC) to surface profile + bit depth even when theVideomaster doesn't. Segment-levelCOMMENT/DESCRIPTION/TITLEand per-trackBPS/NUMBER_OF_FRAMESSimpleTags are surfaced as container / stream facts. -
AVI: RIFF/LIST walker,
avih(width/height/microSecPerFrame) +strl/strh/strf(BITMAPINFOHEADER + WAVEFORMATEX), OpenDMLdmlhfor4 GB frame counts,
INFOtags (INAM/IART/ICMT). -
MPEG-PS / MPEG-TS / M2TS: MPEG-1/2 sequence header decode (resolution, fps, aspect ratio, bit rate), PAT → PMT walk for elementary-stream inventory, ES descriptor loop for language + subtitle type. M2TS (Blu-ray BDAV, 192-byte packets with
TP_extra_header) auto-detected via magic-byte sniff.
// Flat dictionary for CSV/table output
let dict = VideoMetadataExporter.buildDictionary(video)
// JSON string matching ffprobe's general shape
let json = VideoMetadataExporter.toJSONString(video)The CLI surfaces all of the above:
$ swift-exif read --format json path/to/clip.movA 4K HEVC HLG clip straight out of an iPhone:
{
"FileFormat": "MOV",
"FormatLongName": "QuickTime / MOV",
"FileSize": 16309447,
"VideoCodec": "hvc1",
"VideoProfile": "Main 10",
"VideoWidth": 3840,
"VideoHeight": 2160,
"FrameRate": 59.9568655643422,
"AvgFrameRate": 59.9568655643422,
"RFrameRate": 59.9568655643422,
"BitDepth": 10,
"ChromaSubsampling": "4:2:0",
"PixelFormat": "yuv420p10le",
"ColorSpace": "bt2020-hlg",
"ColorPrimaries": 9,
"TransferCharacteristics": 18,
"MatrixCoefficients": 9,
"Duration": 2.316666666666667,
"AudioCodec": "mp4a",
"AudioSampleRate": 48000,
"AudioChannels": 2,
"AudioChannelLayout": "stereo",
"CreationDate": "2025-12-09T14:56:44Z"
}A Blu-ray MKV remux with 31 PGS subtitle tracks and 14 audio streams
returns each track individually under videoStreams / audioStreams /
subtitleStreams, with language tags, flags, and codec IDs preserved.
Standalone MP3, FLAC, M4A, Ogg Opus (.opus), Ogg Vorbis (.ogg/.oga), RIFF WAV / Broadcast WAVE (.wav), and AIFF / AIFC (.aif / .aiff) files expose codec, sample rate, channel count, channel layout, bit depth, and bit rate alongside container-specific tags:
let audio = try AudioMetadata.read(from: mp3URL)
print(audio.codec, audio.codecName) // "mp3", "MP3"
print(audio.sampleRate, audio.channels, audio.bitrate, audio.bitDepth)
print(audio.title, audio.artist, audio.album)Format-specific detail:
- MP3 (ID3v1 / ID3v2): full ID3v2 frame decode including
TXXXuser-defined text,WXXXuser URL,PRIVprivate frames,GEOBgeneral encapsulated objects, andCHAP/CTOCchapter/table-of- contents frames. - FLAC: Vorbis comments plus
SEEKTABLE(sample → byte offsets) andCUESHEET(track index points for CD-DA mastering). - WAV / BWF (RIFF):
LIST INFOtags (INAM/IART/ICMT/ICRD/…), Broadcast WAVEbextv0 / v1 (UMID) / v2 (loudness fields), and iXML — read and write, with thebextchunk rewritten in place preserving the surroundingdata/LISTchunks. - AIFF / AIFC:
NAME/AUTH/(c)/ANNO/COMTchunks decoded from IFF, COMM 80-bit IEEE 754 sample-rate field handled natively — read and write.
Convenience top-level functions parse on a detached task so callers can
await without blocking the main actor. Missing metadata returns nil
rather than throwing — reserve errors for I/O and hard parse failures.
import SwiftMediaMetadata
// C2PA manifests embedded in MP4/MOV (same JUMBF path as AVIF/HEIF).
if let c2pa = try await readVideoC2PAMetadata(from: videoURL) {
let claim = c2pa.activeManifest?.claim
print(claim?.claimGenerator) // "Adobe Premiere Pro 24.0"
print(claim?.claimGeneratorInfo?.name) // "Adobe Premiere Pro"
for assertion in c2pa.activeManifest?.assertions ?? [] {
print(assertion.label) // "c2pa.actions", "c2pa.hash.data", …
}
}
// Camera metadata — Sony NonRealTimeMeta (RDD-18) embedded or sidecar .XML,
// or Blackmagic RAW slate from `moov.meta`.
if let cam = try await readVideoCameraMetadata(from: videoURL) {
print(cam.deviceManufacturer) // "Sony" / "Blackmagic Design"
print(cam.deviceModelName) // "PXW-FX9" / "Pyxis 12K"
print(cam.lensModelName) // "Sony FE 24-70mm F2.8 GM"
print(cam.captureFps) // 23.98 (off-speed FPS for BRAW)
print(cam.captureGammaEquation) // "SLog3" / "Blackmagic Film Gen 5"
}
// Both in one pass (cheaper than calling the two above separately).
let video = try await readVideoMetadata(from: videoURL)When reading CLIP.MP4 or CLIP.MXF, SwiftMediaMetadata automatically probes for
a Sony NonRealTimeMeta sidecar (CLIP.XML, CLIP.xml, CLIP.M01) next
to the clip. If found, its parsed contents populate camera.
// Given: /path/CLIP.MXF next to /path/CLIP.XML
let video = try VideoMetadata.read(from: mxfURL)
video.camera?.deviceManufacturer // pulled from the sidecarAccess embedded C2PA manifests for content authenticity. Coverage:
JPEG (APP11), TIFF (Exif sub-IFD or DNG private tag), PNG (caBX),
JPEG XL, AVIF, HEIF, WebP (C2PA RIFF chunk), GIF (Application
Extension), PDF (catalog /Metadata), MP4 / MOV (uuid or top-level
jumb), MXF (SMPTE UL or Dark KLV), Broadcast WAVE (C2PA LIST
chunk), and external .c2pa sidecars. Hash bindings and ECDSA
signatures are verified against the embedded certificate chain:
if let c2pa = metadata.c2pa {
for manifest in c2pa.manifests {
print(manifest.claim.claimGenerator) // "SONY_CAMERA"
print(manifest.claim.claimGeneratorInfo?.name) // "SONY_CAMERA"
print(manifest.claim.title) // "20251212_TRA_MOV_0224.MP4"
print(manifest.signature.algorithm) // ES256
print(manifest.signature.certificateChain.count)
for assertion in manifest.assertions {
print(assertion.label) // "c2pa.actions.v2", "c2pa.hash.bmff.v3", …
if case .actions(let actions) = assertion.content {
for action in actions.actions {
print(action.action) // "c2pa.created"
print(action.digitalSourceType) // IPTC URL
}
}
}
}
}The JSON exporter surfaces the same fields under HasContentCredentials,
HasSignature, ClaimGenerator, ClaimGeneratorInfoName, ClaimTitle,
ManifestLabel, SignatureAlgorithm, SignatureCertificateCount,
Assertions, ActionsAction, ActionsDigitalSourceType,
ActionsSoftwareAgent — matching the field set consumed by downstream
apps that previously shelled out to ExifTool.
Each assertion is decoded into a typed payload (c2pa.actions(.v2),
c2pa.hash.data / c2pa.hash.bmff(.v3), c2pa.training-mining,
c2pa.thumbnail.*, stds.exif, stds.iptc, stds.schema-org.*)
rather than left as opaque CBOR. Hash assertions are verified against
the asset bytes; signature assertions are verified with ECDSA over the
embedded certificate chain — failures are surfaced via
c2pa.verification rather than thrown.
Camera-specific manufacturer metadata for Canon, Nikon, Sony, Fujifilm,
Olympus, Panasonic, Apple (iPhone / iPad), DJI, Samsung, Pentax, Leica,
and Sigma. Canon and Sony parsers extract array-tag depth (Canon
CameraSettings / ShotInfo / AFInfo2 / FileInfo / SensorInfo, Sony
0x01xx / 0x2xxx / 0xB0xx blocks) — including a curated FE-mount lens
ID table for Sony — alongside basic identifiers (serial, firmware,
lens model). Apple iPhone surfaces Live Photo ContentIdentifier,
HDR image type, burst UUID, and acceleration vector.
if let makerNote = metadata.exif?.makerNote {
print(makerNote.manufacturer) // .canon, .nikon, .sony, .apple, .dji, …
for (name, value) in makerNote.tags {
print("\(name): \(value)")
}
}// Read
if let icc = metadata.iccProfile {
print(icc.colorSpace) // "RGB ", "CMYK", etc.
print(icc.profileDescription) // "sRGB IEC61966-2.1"
}
// Copy ICC profile to another image
var dest = try readMetadata(from: destURL)
dest.iccProfile = metadata.iccProfile
try dest.write(to: destURL)Derived values calculated from raw Exif data:
let composites = CompositeTagCalculator.calculate(from: metadata.exif!)
composites["Megapixels"] // 24.2
composites["LightValue"] // 10.5
composites["FieldOfView"] // 63.7
composites["LensID"] // "EF 24-70mm f/2.8L II USM"
composites["GPSPosition"] // "59.9139 N, 10.7522 E"Apply GPS coordinates from a GPX track to images based on capture time:
let track = try GPXParser.parse(from: gpxFileURL)
var metadata = try readMetadata(from: imageURL)
let matched = metadata.applyGPX(track, maxOffset: 60)
if matched {
try metadata.write(to: imageURL)
}Convert GPS coordinates to city, region, and country names offline — no network access, no API key. Uses an embedded GeoNames database (~33,500 cities with population ≥ 15,000) behind a k-d tree for O(log n) nearest-neighbor lookup.
// Standalone lookup
let geocoder = ReverseGeocoder.shared
if let location = geocoder.lookup(latitude: 59.9139, longitude: 10.7522) {
print(location.city) // "Oslo"
print(location.region) // "Oslo"
print(location.country) // "Norway"
print(location.countryCode) // "NOR" (ISO 3166-1 alpha-3)
print(location.timezone) // "Europe/Oslo"
print(location.population) // 580000
print(location.distance) // 0.3 (km from query point)
}
// Nearest N cities — useful for disambiguating near borders
let nearby = geocoder.nearest(latitude: 59.9139, longitude: 10.7522, count: 5)Populate IPTC/XMP location fields directly from an image's embedded GPS:
var metadata = try readMetadata(from: imageURL)
// Fills IPTC City / Province-State / Country-Name / Country-PrimaryLocationCode
// and the matching XMP fields. Skips fields that are already set unless
// overwrite: true is passed.
if let location = metadata.fillLocationFromGPS() {
print("Matched \(location)")
try metadata.write(to: imageURL)
}Both APIs accept a maxDistance: parameter (km) to reject matches that
are too far away — defaults are 50 km for lookup, 100 km for nearest.
var dest = try readMetadata(from: destURL)
let source = try readMetadata(from: sourceURL)
// Copy all metadata
dest.copyMetadata(from: source)
// Or selective groups
dest.copyMetadata(from: source, groups: [.exif, .iptc])
try dest.write(to: destURL)let a = try readMetadata(from: fileA)
let b = try readMetadata(from: fileB)
let diff = a.diff(against: b)
for change in diff.changes {
print("\(change.type): \(change.key) — \(change.oldValue ?? "nil") → \(change.newValue ?? "nil")")
}if let jpegData = metadata.extractThumbnail() {
try jpegData.write(to: thumbnailURL)
}var metadata = try readMetadata(from: imageURL)
metadata.stripAllMetadata() // Remove everything
metadata.stripGPS() // Remove GPS only
metadata.stripExif() // Remove Exif only
metadata.stripIPTC() // Remove IPTC only
metadata.stripXMP() // Remove XMP only
metadata.stripC2PA() // Remove C2PA only
metadata.stripICCProfile() // Remove ICC profile only
try metadata.write(to: outputURL)var metadata = try readMetadata(from: imageURL)
metadata.shiftDates(by: 3600) // Shift all dates forward by 1 hour
try metadata.write(to: imageURL)Process files that match specific conditions:
let condition: MetadataCondition = .and([
.equals(field: "IPTC:City", value: "Oslo"),
.greaterThan(field: "Exif:FocalLength", value: 50)
])
let result = try BatchProcessor.processDirectory(
at: directoryURL,
where: condition,
recursive: true
) { metadata in
metadata.iptc.setValue("© 2026 Agency", for: .copyrightNotice)
}Rename files using metadata-driven templates:
let renamer = MetadataRenamer(
template: "%{DateTimeOriginal:yyyyMMdd}_%{IPTC:City}_%c",
counterDigits: 3
)
// Preview before renaming
let preview = renamer.dryRun(files: imageURLs)
for (from, to) in preview {
print("\(from.lastPathComponent) → \(to.lastPathComponent)")
}
// Perform rename
let result = renamer.rename(files: imageURLs)
print("\(result.renamed.count) files renamed")// JSON
let json = MetadataExporter.toJSONString(metadata)
// Human-readable JSON with print conversions
let readable = MetadataExporter.toReadableJSON(metadata)
// XML
let xml = MetadataExporter.toXML(metadata)
// CSV (multiple files)
let csv = CSVExporter.toCSV(metadataArray, fields: ["IPTC:Headline", "Exif:Make"])Convert raw numeric values to human-readable strings:
let readable = PrintConverter.buildReadableDictionary(metadata)
// "Orientation" → "Rotate 90 CW" (instead of 6)
// "ExposureTime" → "1/250" (instead of rational)
// "Flash" → "Fired, Return detected" (instead of 15)let result = try BatchProcessor.processDirectory(at: directoryURL, recursive: true) { metadata in
metadata.iptc.setValue("© 2026 Agency", for: .copyrightNotice)
}
print("\(result.succeeded) files updated, \(result.failed.count) errors")Sources/SwiftMediaMetadata/
├── API/ # Public API: ImageMetadata, BatchProcessor, FormatDetector,
│ # MetadataExporter, CSVExporter, PrintConverter,
│ # MetadataRenamer, CompositeTagCalculator
├── Binary/ # Low-level binary readers/writers, CRC32, ISO BMFF
├── Exif/ # Exif IFD parsing and writing
├── IPTC/ # IPTC IIM reader/writer (Records 1/2/3/6/7/8 + PLUS), Photoshop IRB
├── XMP/ # XMP reader/writer with namespace mapping
├── C2PA/ # C2PA manifest/claim/signature parsing + ECDSA verification
├── CBOR/ # CBOR decoder for C2PA payloads
├── MakerNote/ # Camera-specific MakerNote parsers (12 manufacturers)
├── GPX/ # GPX track parser and geotagging
├── Geolocation/ # Offline reverse geocoder (GeoNames + k-d tree)
├── ICC/ # ICC color profile reader (TRC, primaries, A2B/B2A LUTs, chad)
├── JPEG/ # JPEG segment parser and writer (incl. CIPA DC-007 MPF)
├── TIFF/ # TIFF/RAW file parser and writer
├── RAW/ # Camera RAW format support (DNG, CR2/CR3, NEF, ARW, RAF,
│ # RW2, ORF, PEF, IIQ, 3FR, FFF, X3F, MRW, …)
├── CR3/ # Canon CR3 (ISOBMFF) parser and writer
├── PNG/ # PNG chunk parser and writer
├── JPEGXL/ # JPEG XL box parser and writer
├── AVIF/ # AVIF (ISOBMFF) parser and writer
├── HEIF/ # HEIF/HEIC parser and writer with auxiliary image walk
├── WebP/ # WebP (RIFF container) parser and writer
├── GIF/ # GIF parser/writer (XMP + C2PA Application Extension)
├── BMP/ # BMP header reader
├── SVG/ # SVG XMP metadata reader/writer
├── PDF/ # PDF document metadata + catalog `/Metadata` XMP reader/writer
├── PSD/ # Photoshop document parser and writer
├── Apple/ # AAE sidecar parser (Apple Photos edit decisions)
├── Audio/ # MP3/FLAC/M4A/Ogg/WAV/AIFF tag and codec readers + writers
└── Video/ # MP4/MOV/M4V/MXF/MKV/WebM/AVI/MPEG-TS metadata parsers,
# Sony NonRealTimeMeta (NRT / RDD-18) XML parser, GoPro GPMF
Measured with Sources/Benchmark/main.swift (release build) against a 382 KB JPEG sample, writing 8 IPTC fields + 8 XMP fields, and reading all of IPTC/XMP/EXIF. C2PA and MP4 parse benchmarks use synthetic JUMBF manifest stores.
Write / Read (100 files)
| Operation | exiftool batch | exiftool sequential | SwiftMediaMetadata sequential | SwiftMediaMetadata batch |
|---|---|---|---|---|
| Write | 18.0 ms/file | 267.7 ms/file | 8.2 ms/file | 2.8 ms/file |
| Read | 22.6 ms/file | — | 4.5 ms/file | 2.8 ms/file |
SwiftMediaMetadata batch write is ~6× faster than exiftool batch and ~95× faster than exiftool sequential. Read is ~8× faster than exiftool batch.
C2PA JUMBF parse (1 000 iterations)
| Payload | Size | Per parse |
|---|---|---|
| Small (1 manifest, 2 assertions) | 1.2 KB | 29.0 µs |
| Medium (3 manifests, 5 assertions) | 5.3 KB | 120.0 µs |
| Large (10 manifests, 10 assertions) | 27.4 KB | 590.3 µs |
MP4 container + C2PA parse (1 000 iterations) — full MP4Parser.parse pass over a synthetic ftyp / moov / uuid container with embedded JUMBF.
| Payload | Container | Per parse |
|---|---|---|
| Small | 1.3 KB | 31.9 µs |
| Medium | 5.4 KB | 122.7 µs |
| Large | 27.5 KB | 604.0 µs |
Tested 2026-04-19 on macOS 26.4.1 (Apple M1 Max, 10 cores). SwiftMediaMetadata @ c84bfee (main). ExifTool 13.55 via Homebrew.
- GeoNames (https://www.geonames.org/) — The reverse geocoding database is built from GeoNames geographical data, licensed under Creative Commons Attribution 4.0. The embedded city database contains ~33,500 cities with population >= 15,000.
- ExifTool by Phil Harvey (https://exiftool.org/) — The reference implementation for image metadata processing. SwiftMediaMetadata aims to provide equivalent functionality as a native Swift library.
GPL-3.0 — see LICENSE for details.