How a Live Photo stores your location twice
Apple’s .pvt package is the first thing OmniViewer opens that is not a file at all — it is a folder. Inside are the two halves of a Live Photo, and both of them write down where you were standing, in structures that have nothing to do with each other. That is why “remove EXIF” does not remove it.
A file that is a folder
Drag a Live Photo out of Photos on a Mac and you may end up with something ending in .pvt. Finder shows it as one item with one icon. It is not one item. It is a directory carrying the macOS bundle bit — the same trick that makes .app and .rtfd look like files. Apple calls this one a video-complement package.
Ask the shell instead of the Finder and the illusion drops:
$ file IMG_1673-preview.pvt
IMG_1673-preview.pvt: directory
$ ls -la IMG_1673-preview.pvt
-rw-r--r-- 2219588 IMG_1673.HEIC
-rw-r--r-- 2554722 IMG_1673.MOV
-rw-r--r-- 256 metadata.plist
Three members: the still frame, the motion, and a manifest. Every file viewer on the web either refuses the folder outright or hands you an empty zero-byte document, because the whole web file-input model assumes one file per drop.
What is actually inside
The still is an ordinary HEIC — HEVC-coded pixels in an ISO-BMFF box tree. The motion is an ordinary QuickTime movie; on a recent iPhone it is HEVC too, and it carries far more than a video track:
top-level boxes: ftyp, wide, mdat, moov
tracks: video/hvc1
auxv/hvc1 ×4 ← depth and alpha maps
audio/lpcm
metadata/mebx ×4 ← per-frame timed metadata
duration: 1.67 s
The manifest is the smallest and most literal file in the package — 256 bytes whose entire job is to stamp a version number on the pairing:
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>PFVideoComplementMetadataVersionKey</key>
<string>1</string>
</dict>
</plist>
PF is PhotoFoundation, the framework behind Photos. The still and the motion are paired by filename stem — IMG_1673 — not by anything in this plist. (New to the format? What is a .pvt file covers the extension itself and dumps every member in hex.)
ImageCaptureRequestID and PhotoIdentifier, while the MOV held com.apple.quicktime.content.identifier — three different UUIDs. OmniViewer therefore shows them as separate facts and pairs the members by stem, rather than claiming a link it cannot demonstrate.
The coordinate, twice
Here is the part that matters. Read the GPS out of the still, then read the location out of the motion, and compare:
| Member | Structure | Value |
|---|---|---|
IMG_1673.HEIC | EXIF GPS IFD (GPSLatitude, GPSLongitude, GPSAltitude…) | +43.6706, -79.3936 |
IMG_1673.MOV | com.apple.quicktime.location.ISO6709 | +43.6706-079.3936+116.688/ |
The same place, written down twice, in two formats that share no code path. The still uses the TIFF-derived EXIF tag structure, where latitude is three rationals and a separate N/S reference tag. The movie uses a single ISO 6709 string in a reverse-DNS-keyed mdta metadata atom, sitting inside moov.
The mdta path is worth reading closely, because it is the one people miss. QuickTime stores these under moov/meta as a keys table plus an ilst whose item types are indexes into it:
moov
└─ meta
├─ keys ← ["com.apple.quicktime.location.ISO6709",
│ "com.apple.quicktime.make", ...]
└─ ilst
├─ [1] ← item type 1 = keys[0]
│ └─ data ← "+43.6706-079.3936+116.688/"
└─ [2] ...
An older tool that only walks the iTunes-style moov/udta/meta/ilst path finds nothing here and reports the movie as having no metadata at all — which is how a file with your coordinates in it gets a clean bill of health.
Reading both halves
OmniViewer’s /pvt worker is deliberately not new parsing code. The still goes through the same HEIF/EXIF readers /heic uses, the motion through the same ISO-BMFF reader /mp4 uses. What is new is reading both and putting the answers next to each other:
if (out.still?.gps) {
out.gps.push({
member: out.still.name, role: 'still',
where: 'EXIF GPS IFD',
value: out.still.gps.decimal,
});
}
if (out.motion?.gps) {
out.gps.push({
member: out.motion.name, role: 'motion',
where: out.motion.gpsAtom, // com.apple.quicktime.location.ISO6709
value: out.motion.gps,
});
}
The METADATA tab opens with that list, naming every copy it found, which member it came from and which structure held it. Two entries is the normal answer for an iPhone Live Photo. Nothing is geocoded: the coordinate is read out of your file and never sent anywhere — the page’s CSP has no reachable tile host, and looking one up would hand a third party the exact coordinate the tab exists to help you delete.
Opening a 20 GB package
A Live Photo is a few megabytes, but nothing in the toolkit assumes that. Members are held as disk-backed File handles and never buffered.
The still is easy: HEIF puts the whole item structure in one small meta box near the front, so the EXIF is a couple of small reads however large the coded image is.
The motion is the interesting one. Its metadata lives in moov — and QuickTime is free to write moov after the media. iOS does exactly that. In the sample package shipped with this article, moov starts at byte 176 488 of a 178 099-byte file: the index is the last thing in the file. So the reader walks top-level box headers, sixteen bytes at a time, and seeks straight to it:
let off = 0;
while (off + 8 <= file.size) {
const h = await read(file, off, 16); // 16 bytes, not the box
let size = readU32(h, 0);
const type = str(h, 4, 4);
if (size === 1) size = readU64(h, 8); // 64-bit 'largesize'
else if (size === 0) size = file.size - off;
layout.push({ type, start: off, size });
off += size; // skip mdat entirely
}
const moov = layout.find((b) => b.type === 'moov');
const moovBytes = await read(file, moov.start, moov.size);
The loop never touches mdat. A four-gigabyte media box costs one addition. Playback is an object URL handed to a <video> element, so the browser streams the track off disk and no media bytes pass through JavaScript at all. A package whose motion is a twenty-gigabyte take opens on the same handful of reads as a three-second one.
Getting a folder into a browser
The last problem is the first one: a web page cannot open a directory the ordinary way. dataTransfer.files gives a zero-byte entry for a dropped folder. The entries API is the way through — with one sharp edge:
document.addEventListener('drop', (event) => {
// webkitGetAsEntry MUST be called synchronously. The item list is
// neutered the moment this handler returns, so a single `await`
// before this line yields nothing at all.
const entry = firstDirectoryEntry(event.dataTransfer);
if (entry) {
readDirectoryEntry(entry).then((files) => onBundle({ name: entry.name, files }));
return;
}
onFile(event.dataTransfer.files[0]);
});
The second edge is in the directory reader itself: readEntries() returns at most about a hundred entries per call and signals the end with an empty batch, so it has to be drained in a loop even for a three-member package.
Because a folder cannot be fetched over HTTP or attached to a message, OmniViewer also accepts a zipped package. Members are stored rather than deflated, so expanding one is a slice() against the central directory and no decompressor is involved — which is how the sample on the /pvt page can exist at all.
Once the members exist, the package rides through the rest of the app as its primary member: the still becomes the file the RAW and HEX views point at, with the manifest carried alongside. The byte-window engine, the hex editor and the download path never learn that packages exist, and they keep working at any size.