The Movie Metadata Format (.mov embedded lens metadata)#
The byte-layout spec for the camera metadata CaptureGraph bakes into a recorded
.mov, so any reader — a Python analysis pass, a third-party player, another
platform's recorder — recovers it without reverse-engineering the writer. It
completes the interoperability standard for video captures:
the captured-data format moves the file, and this
page says what is inside it beyond the pixels.
Container#
QuickTime .mov, written by an AVAssetWriter. Camera metadata rides in QuickTime
metadata items (AVMetadataItem) in the mdta key space, in two scopes:
- Asset-level snapshot — one item on the movie's metadata, a whole-clip average.
- Per-frame timed metadata track — a
.metadatatrack carrying one item per video frame at that frame's presentation time.
Both scopes use the same identifier and the same value encoding, so a reader decodes intrinsics identically wherever they appear.
Camera intrinsics#
| Field | Value |
|---|---|
| Key space | mdta (AVMetadataKeySpace.quickTimeMetadata) |
| Key | edu.cornell.video.intrinsics |
| Full identifier | mdta/edu.cornell.video.intrinsics |
| Data type | com.apple.metadata.datatype.UTF-8 (kCMMetadataBaseDataType_UTF8) |
| Value | UTF-8 JSON of a CameraIntrinsics |
| Language tag | none |
CaptureGraph coins this key, so it lives under edu.cornell.* — the owner-of-bytes
namespace rule the scalar vocabulary also follows.
The video. segment scopes it to embedded-video metadata keys, kept apart from the
scalar type-name space: edu.cornell.pose already names the Pose scalar, whose byte
layout differs from this key's JSON payload.
The value is the JSON encoding of CameraIntrinsics — byte-identical to the JSON the
still pipeline stashes in the EXIF user comment, so intrinsics are one format across a
capture's stills and its movies. Its fields (sensor-native pinhole, in pixels, referenced
to the buffer's landscape pixel grid):
{
"focal_x": 3312.5,
"focal_y": 3313.1,
"center_x": 2015.7,
"center_y": 1511.2,
"reference_width": 4032,
"reference_height": 3024,
"distortion": null
}
focal_x/focal_y are the focal lengths (matrix diagonal); center_x/center_y the
principal point (matrix third column); reference_width/reference_height the pixel
dimensions of the frame the matrix was measured against. distortion is null for the
per-frame track and the live snapshot (the live video stream carries no distortion
model); it is populated only where a still resolves one. The matrix is not rotated
into display orientation — it stays aligned to the sensor-native landscape buffer, so it
composes with the video track's orientation transform, never double-applied.
Per-frame track timing#
Each frame's item is one AVTimedMetadataGroup over [pts, pts + duration), where pts
is the video frame's presentation time in the movie timeline (the writer's session starts
at the first frame's PTS) and duration is the frame's sample duration — so an entry's
start equals the matching video frame's PTS. That shared PTS is the frame↔intrinsics
correspondence. A frame the sensor delivered without an intrinsic matrix leaves a gap
(never fabricated data); the track is present only when intrinsic-matrix delivery is
active, so it is never empty. The .metadata track's format description declares the
single identifier above with the UTF-8 data type.
Source#
The matrix comes from the kCMSampleBufferAttachmentKey_CameraIntrinsicMatrix attachment
AVFoundation delivers on each CMSampleBuffer when
isCameraIntrinsicMatrixDeliveryEnabled — a matrix_float3x3, column-major: column 0 =
(focal_x, 0, 0), column 1 = (0, focal_y, 0), column 2 = (center_x, center_y, 1). The
writer reads it from the exact buffers it encodes (CameraIntrinsics(sampleBuffer:)), so
the reading and the frame share a presentation time with no second read.
Camera pose (ARKit posed video)#
The ARKit posed-video recorder adds a second identifier to this same per-frame track
for the 6-DoF world transform, keeping the intrinsics identifier and encoding above
unchanged so both writers share one format description. Each frame's
AVTimedMetadataGroup then carries two items — the intrinsics item above and the pose
item below — over the same [pts, pts + duration) range, so a reader recovers intrinsics
and pose for that frame at one presentation time.
| Field | Value |
|---|---|
| Key space | mdta (AVMetadataKeySpace.quickTimeMetadata) |
| Key | edu.cornell.video.pose |
| Full identifier | mdta/edu.cornell.video.pose |
| Data type | com.apple.metadata.datatype.UTF-8 (kCMMetadataBaseDataType_UTF8) |
| Value | UTF-8 JSON { "world_transform": [16 doubles] } |
| Language tag | none |
world_transform is the camera's 4×4 world transform (ARKit world space, gravity-aligned)
as sixteen doubles in column-major order — the layout simd_float4x4.columns.0…3 lay
out, so the first four entries are column 0, and entries 12–15 are the fourth column (the
translation (x, y, z, 1)). It is the same pose ARKit reports for the frame, so it is
aligned to the sensor-native landscape buffer, never rotated into display orientation.
This baked-in pose is a self-contained bonus — a player recovers per-frame pose without
the CaptureGraph sidecars. The schema-advertised truth stays the PosedVideo.poses map
(Map[Time, Pose]), which additionally carries the decomposed position/quaternion,
intrinsics, tracking state, and capture time per frame. Like the intrinsics track, a pose
item carries no extendedLanguageTag: the metadata adaptor matches an item to a
format-description entry by the full (identifier, dataType, extendedLanguageTag) tuple and
raises an uncatchable NSException on a miss.
Sidecar alignment (the frame ↔ pose ↔ depth contract)#
The recorder guarantees, and readers may rely on:
- One pose entry per video frame, atomically. A frame's video sample, its metadata
items, its pose entry, and its depth entry (when the sensor measured one) are appended
together or dropped together — a frame the writer skips (input not ready, failed
append) leaves no artifact anywhere. So the i-th pose in key order is the i-th
video frame in presentation order, for every i; readers decode a frame by counting,
never by
elapsed-time × average fps(the clip is variable-rate — ARKit drops frames under load). - Sidecar keys are affine in PTS. The session-clock → Unix-epoch offset is latched
once per recording, so every sidecar key equals
offset + PTSexactly: keys are strictly monotone in frame order and their deltas equal the movie-timeline deltas (immune to wall-clock steps mid-recording). - Depth keys are a subset of pose keys. A depth entry reuses the same key value as its frame's pose entry — frame↔depth correspondence is exact key equality, never a nearest-timestamp match.