iCloud Photos exports everything as HEIC — Apple's high-efficiency format that Windows, most web services, and older software can't read. A typical "export all" from iCloud.com produces a folder of .heic files that nothing outside the Apple ecosystem can open. The conversion itself is trivial for a single file; at 2,000 photos it becomes a batch processing problem. This guide covers the full pipeline for taking an iCloud export to a folder of universally-readable JPGs.
TL;DR — throughput for typical iCloud exports
Real-world HEIC conversion benchmarks via the API:
| Export size | Avg HEIC size | Time (10 workers) | Plan needed |
|---|---|---|---|
| 100 photos | 4MB | ~2 min | Free (1K/mo) |
| 500 photos | 4MB | ~8 min | Free (1K/mo) |
| 2,000 photos | 4MB | ~30 min | Hobby ($29/mo) |
| 10,000 photos | 4MB | ~2.5 hr | Startup ($99/mo) |
HEIC conversions take 1.5-3s per file (larger than most PNG/JPG conversions due to HEIC codec complexity). Budget accordingly.
Why HEIC batch conversion is painful locally
Local HEIC conversion requires a native codec:
- macOS:
sipsworks but runs serially and has no progress tracking. 1,000 files takes ~45 minutes. - Linux: Requires
libheif+ ImageMagick built with HEIF support. The default Ubuntu libheif is often outdated and fails on recent iPhone HEIC variants. - Windows: No built-in HEIC support in CLI tools. Microsoft's HEVC codec is paid ($0.99). PowerShell workarounds are unreliable.
The API approach skips all of this: POST the HEIC file, receive a JPG. No local codec, no platform differences, and 10x faster than serial local conversion on macOS.
Full iCloud export conversion script
#!/usr/bin/env python3
"""Convert iCloud HEIC export to JPG.
Usage:
CTF_API_KEY=ctf_sk_... python3 convert_icloud.py ~/Downloads/iCloud-Export ./jpg-output
"""
import asyncio
import hashlib
import json
import os
import sys
from pathlib import Path
import httpx
API_KEY = os.environ['CTF_API_KEY']
API_URL = 'https://changethisfile.com/v1/convert'
CONCURRENCY = 10
def idem_key(path: Path) -> str:
stat = path.stat()
payload = f"{path.resolve()}|jpg|{stat.st_size}|{stat.st_mtime_ns}"
return hashlib.sha256(payload.encode()).hexdigest()[:32]
def mirror_path(source_root: Path, source_file: Path, output_root: Path) -> Path:
"""Preserve directory structure in output."""
rel = source_file.relative_to(source_root)
return (output_root / rel).with_suffix('.jpg')
async def convert_heic(
client: httpx.AsyncClient,
heic_path: Path,
out_path: Path,
sem: asyncio.Semaphore,
done: set,
failures: list,
) -> bool:
if str(out_path) in done:
return True
if out_path.exists():
done.add(str(out_path))
return True
async with sem:
for attempt in range(3):
try:
content = heic_path.read_bytes()
resp = await client.post(
API_URL,
headers={
'Authorization': f'Bearer {API_KEY}',
'Idempotency-Key': idem_key(heic_path),
},
content=content,
params={'target': 'jpg'},
timeout=120,
)
if resp.status_code == 429:
retry = int(resp.headers.get('Retry-After', '60'))
await asyncio.sleep(retry + attempt * 10)
continue
if resp.status_code >= 500:
await asyncio.sleep(2 ** attempt)
continue
resp.raise_for_status()
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(resp.content)
done.add(str(out_path))
return True
except httpx.TimeoutException:
await asyncio.sleep(2 ** attempt)
failures.append({'source': str(heic_path), 'output': str(out_path)})
return False
async def main(source_root: Path, output_root: Path):
heic_files = sorted(
p for p in source_root.rglob('*')
if p.suffix.lower() in ('.heic', '.heif')
)
print(f'Found {len(heic_files)} HEIC/HEIF files')
checkpoint = Path('.heic_done.json')
done = set(json.loads(checkpoint.read_text())) if checkpoint.exists() else set()
failures = []
sem = asyncio.Semaphore(CONCURRENCY)
completed = 0
async with httpx.AsyncClient() as client:
tasks = [
convert_heic(
client,
f,
mirror_path(source_root, f, output_root),
sem,
done,
failures,
)
for f in heic_files
]
for coro in asyncio.as_completed(tasks):
ok = await coro
completed += 1
if completed % 50 == 0:
checkpoint.write_text(json.dumps(sorted(done)))
pct = completed / len(heic_files) * 100
print(f'[{completed}/{len(heic_files)}] {pct:.0f}% done')
checkpoint.write_text(json.dumps(sorted(done)))
ok_count = len(heic_files) - len(failures)
print(f'Done: {ok_count}/{len(heic_files)} converted')
if failures:
print(f'Failed ({len(failures)}):')
for f in failures[:10]:
print(f" {f['source']}")
if len(failures) > 10:
print(f' ... and {len(failures)-10} more (see failures.json)')
Path('failures.json').write_text(json.dumps(failures, indent=2))
if __name__ == '__main__':
if len(sys.argv) != 3:
print(f'Usage: {sys.argv[0]} ')
sys.exit(1)
asyncio.run(main(Path(sys.argv[1]), Path(sys.argv[2])))
Shell one-liner for quick batches using gnu parallel:
# Requires: gnu parallel, curl
find ~/Downloads/iCloud-Export -iname '*.heic' | \
parallel -j 10 --bar \
'mkdir -p ./jpgs/$(dirname {#}) && \
curl -s -X POST https://changethisfile.com/v1/convert \
-H "Authorization: Bearer $CTF_API_KEY" \
-F "file=@{}" -F "target=jpg" \
-o "./jpgs/{/.}.jpg"'
iCloud export quirks to know
iCloud exports have some gotchas that affect conversion pipelines:
- HEIC vs HEIF extension: iCloud exports use
.heicbut some iOS versions export as.heif. The script above handles both —rglob('*')with suffix check on.heicAND.heif(case-insensitive). - Live Photos export as both HEIC + MOV: A live photo produces a
photo.heicandphoto.movwith the same stem. The conversion script ignores MOV files. If you want the still frame, converting the HEIC is correct. If you want the video, convert the MOV to MP4 separately. - Burst photos: iCloud exports bursts as individual HEIC files named
IMG_1234.HEICthroughIMG_1234~5.HEIC. The~Nsuffix is preserved in the stem when mirroring to output — no collisions. - iCloud folder structure: Exports from iCloud.com use a flat structure. Exports from the Photos app use date-based subfolders. The mirror_path function handles both.
EXIF metadata after conversion
HEIC files carry rich EXIF metadata: GPS coordinates, camera make/model, capture date, aperture, shutter speed. The API preserves EXIF data in the output JPG — GPS, date/time, camera metadata all transfer correctly.
Verify EXIF transfer after conversion:
# pip install Pillow
python3 -c "
from PIL import Image
from PIL.ExifTags import TAGS
img = Image.open('output.jpg')
exif = img._getexif()
for tag_id, val in (exif or {}).items():
tag = TAGS.get(tag_id, tag_id)
print(f'{tag}: {val}')
"
If your downstream tool needs GPS in a specific format (e.g., decimal degrees for mapping), use exifread or piexif to parse the rational number format EXIF uses for GPS coordinates.
Progress and cost tracking
For 2,000+ photo exports, add a time estimate to your progress output:
import time
start = time.monotonic()
async def main_with_timing(...):
...
for i, coro in enumerate(asyncio.as_completed(tasks), 1):
ok = await coro
elapsed = time.monotonic() - start
rate = i / elapsed
remaining = (len(heic_files) - i) / rate
print(f'\r[{i}/{len(heic_files)}] {rate:.1f} files/s, ~{remaining/60:.0f}m left', end='')
Cost math: at $29/month (10K conversions), 2,000 photos = $5.80 of your monthly budget. For a one-time migration, Hobby plan covers up to 10K photos per month. For 50K photos, a single-month Startup plan ($99) is the most cost-effective: convert everything, then downgrade.
An iCloud photo export is one of the most common reasons people need batch HEIC conversion. The API handles all HEIC variants (H.265 HEIC, HEIF, iOS 16/17 formats) without local codec requirements. Free tier covers 1,000 photos — enough to validate the output quality before running your full library.