If your upload service converts HEIC with a plain ffmpeg -i photo.heic out.png, check the dimensions of what it stored. On my test file, ffmpeg 7.1 exited cleanly, wrote a valid PNG, and that PNG was 512×512. The photo is 3264×2448. Nothing in the logs looks wrong, and a thumbnail generated from a 512 px square looks plausible enough that nobody complains until someone opens the full image and finds the top-left corner of an airport roof.
A note on the sample before any numbers. I don't have a publishable iPhone original, so I took a CC0 photo from Wikimedia Commons, shot on an iPhone 6 at Madrid airport (3264×2448 JPEG), and encoded it to HEIC with macOS 26.5's built-in sips. I made three variants: landscape, a copy with EXIF orientation set to 6 to act as a portrait shot, and one upscaled to 4032×3024. These are not straight-from-camera files. What they share with iPhone HEICs is the part that matters here, the 512 px tile grid and the irot rotation property. I have no real-device samples for HDR gain maps or Live Photos, so none of that is covered.
The primary image is a grid, not a frame
In a HEIF file the primary item isn't a single HEVC picture. It's a derived item of type grid with no pixels of its own, pointing via dimg references at a set of independently coded hvc1 tiles that the decoder is expected to stitch. The quickest way I found to see that was to walk the meta box and count item types:
import collections, struct, sys
def walk(b, off, end, seen):
while end - off >= 8:
size, kind = struct.unpack('>I4s', b[off:off+8]); kind = kind.decode('latin1')
if kind in ('meta', 'iinf', 'iprp', 'ipco'): # containers: skip their headers
walk(b, off + 8 + {'meta': 4, 'iinf': 6}.get(kind, 0), off + size, seen)
elif kind == 'infe': seen[b[off+16:off+20].decode()] += 1 # item type (infe v2)
elif kind == 'irot': seen[f'irot={(b[off+8] & 3) * 90}'] += 1
off += size
seen = collections.Counter(); raw = open(sys.argv[1], 'rb').read()
walk(raw, 0, len(raw), seen); print(dict(seen))
The landscape file prints {'hvc1': 35, 'grid': 1, 'Exif': 1, 'mime': 1, 'irot=0': 1}: 35 tiles, 7 columns by 5 rows, plus EXIF and an XMP mime item. The 4032×3024 variant has 48 tiles. The portrait one reports irot=270, which comes back below.
Sample: Wikimedia Commons CC0 photo (shot on iPhone 6), encoded to HEIC with the macOS system encoder.
ffmpeg does see the grid. Its input dump contains Stream group #0:0 Tile Grid ... 3264x2448 (default). The default conversion just doesn't composite it and hands you the first tile. I tried selecting the stream group directly with -map 0:g:0 and got Conversion failed!, and I stopped there, so I'm not going to claim a correct flag set. What I'm confident about is the guard: after conversion, compare output dimensions with the grid dimensions and fail the job if they differ.
Pillow fails in a friendlier way. Pillow 11.3 without the pillow-heif plugin raises UnidentifiedImageError: cannot identify image file. Loud failures get caught in staging. The one that bites is a dev machine with the plugin and a production image without it.
Rotation lives in two places
The portrait sample carries irot with 270° on the primary item, so orientation is an image property the decoder applies, not only an EXIF hint. For comparison I converted it with sips: the JPEG it writes keeps pixels at 3264×2448 landscape and sets EXIF Orientation to 6 (model iPhone 6 and other fields preserved). Finder and browsers honour the tag, so it looks right on your laptop. A thumbnailer that resizes raw pixels, or a privacy step that strips EXIF, will serve it sideways. The opposite mistake is just as easy in a multi-service pipeline: one step rotates the pixels, another keeps Orientation 6, and the image gets turned twice. I still don't have anything better for that than an end-to-end test with a portrait fixture.
JPEG can be bigger than the HEIC
I measured sizes in the browser converter of imging, a tool I help build, since it reports before and after. The portrait sample is 745.1 KB. JPEG at the default quality 88 came out at 1.01 MB, 38% larger. The 4032×3024 sample went from 840.1 KB to 1.32 MB, 61% larger, and still 17% larger at quality 80. WebP at its default quality 84 was 423.2 KB, 43% smaller. That's one photo, so treat the percentages as a warning about defaults rather than a ratio to plan storage with.
Where to convert
In the browser: in Playwright's bundled engines on macOS, Chromium 149 and Firefox 151 couldn't display HEIC via <img> or createImageBitmap. WebKit 26.5 could, using the system decoder. I didn't test real Safari, iOS or Windows. Covering every browser means shipping a WASM decoder. That's what imging does for reading HEIC: a one-time "load decoder" step pulls a libheif build, then stitching and rotation happen locally and the JPEG comes out as 2448×3264 pixels with Orientation 1. Writing HEIC as an output goes through its server. I didn't measure decoder size or decode time.
On the server: consistent results for every client and logs you can grep. You own all of it, though: the plugin in the production image, stitching the full grid, reconciling irot with EXIF, and choosing an output format and quality instead of inheriting JPEG 88.
On the phone: Settings › Camera › Formats › Most Compatible. Apple's support page says new photos and videos are then captured as JPEG and H.264. That only affects future shots and depends on users changing a setting.
None of these is free, and which one fits depends on your clients and how many services touch EXIF. The two asserts, full grid dimensions and rotation applied exactly once, are useful whichever you pick. If you want to compare your own HEIC files against an in-browser decode, imging is at https://imging.ai/

Top comments (0)