Skip to content

Why .mov Files Fail on iPhone — and What Actually Fixes It

Updated

If you have ever dropped a .mov from your iPhone into a browser-based tool and watched it fail — while the exact same recording worked on your laptop — this page explains why. The cause is not the codec, not the file size, and not your phone. It is two bytes in the file’s container.

We hit this in our own tool. Analytics showed a number too clean to be a coincidence: .mov uploads on mobile failed 100% of the time. Not 90%. Every single one. Desktop had never reported a single .mov failure.

The reproduction

Take one AAC audio track and put it in two containers — same encoder, same audio bytes, only the wrapper differs. Then ask the browser to decode it:

const buf = await file.arrayBuffer();
await new AudioContext().decodeAudioData(buf);

On an iPhone 17 Pro simulator (iOS 18.7 / Safari 26.5):

FileiOS SafariChromium
sample.mov (ftyp qt )EncodingError: Decoding failedOK
sample.mp4 (ftyp isom)OKOK

Identical audio. The container decides.

The obvious fix that does not work

First instinct: it is the brand in the ftyp box. Patch qt to isom — four bytes, done.

It still fails. Worth writing down so nobody else spends an afternoon on it. Safari is not reading the brand. The difference lives deeper, inside moov.

The root cause

Follow the box tree down to moov → trak → mdia → minf → stbl → stsd — the sample description that tells a decoder how the audio is encoded. Both files carry an mp4a entry. They are not the same mp4a entry:

QuickTime writes:                    MP4 expects:
  version       = 1          <—        version       = 0
  compressionID = -2 (fffe)  <—        compressionID = 0
  + 16 bytes of v1 extension <—        (absent)
  esds wrapped in a 'wave' box <—      esds is a direct child
  extra 'chan' channel layout          (absent)

iOS Safari’s decodeAudioData only accepts a version 0 audio sample entry. Chromium accepts both — which is precisely why desktop never saw this and mobile never survived it.

That version field is a uint16. Two bytes decide whether your recording opens.

This is also why the failure is so absolute. It is not a size limit, not a memory pressure issue, not a slow phone. A QuickTime container written by the iPhone camera is structurally something Safari’s own audio decoder declines to read.

The fix: rebuild the container, leave the codec alone

The audio bitstream inside a .mov is already valid AAC. Nothing needs re-encoding. The work is pure byte plumbing: extract the audio samples, write a fresh audio-only MP4 with a well-formed stsd, and hand that to the browser’s native decoder.

.mov ──► scan top-level boxes to find moov (read box headers only, never touch mdat)
      ──► parse the audio track's stsd / stts / stsc / stsz / stco|co64
      ──► sliding window (8 MB) to copy out the audio samples
      ──► rebuild ftyp + moov (stsd forced to version 0) + mdat
      ──► decodeAudioData

Because we never decode audio ourselves, this needs no WebCodecs — and so does not inherit AudioDecoder’s iOS 17+ floor — and no multi-megabyte ffmpeg.wasm to solve what is a container-level problem.

The sample entry we emit is deliberately boring:

box('mp4a',
  zeros(6),
  u16(1),  // data_reference_index
  u16(0),  // version — the only one iOS Safari accepts
  u16(0),  // revision
  u32(0),  // vendor
  u16(channels),
  u16(16), // samplesize
  u16(0),  // compressionID — QuickTime writes -2; MP4 says 0
  u16(0),  // packetsize
  u32(Math.min(sampleRate, 65535) * 65536), // 16.16 fixed point
  esds     // pulled out of QuickTime's 'wave' wrapper when necessary
)

Two parsing details worth stealing if you implement this yourself:

The esds descriptor moves. In MP4 it is a direct child of mp4a; QuickTime buries it inside a wave box. So scan all siblings first, then recurse — otherwise a wave-nested esds can shadow the one you want.

Sample entry versions have different header lengths. Version 1 carries 16 extra bytes before its child boxes; version 2 carries 36, with the real sample rate in a float64. Get the offset wrong and you start parsing audio data as a box header:

const version = view.getUint16(base + 8);
let childStart = base + 28;
if (version === 1) childStart = base + 28 + 16;
else if (version === 2) { sampleRate = view.getFloat64(base + 32); childStart = base + 64; }

The bug we were not looking for

The old code began with one innocuous line:

const buf = await file.arrayBuffer();  // 161 MB on average, on a phone

Mobile video files in our logs average 161.9 MB — and 99% of those bytes are video frames, none of which a transcript needs. Reading the whole file into memory and then decoding it into an equally long PCM buffer is an efficient way to get terminated by iOS Safari’s memory limits.

Locating boxes by reading only their 8–16 byte headers, then copying samples through an 8 MB sliding window, changes peak memory from the whole file to 8 MB plus the audio itself.

Measured on device

FileNative decodeAfter extractionExtract timeDecode after
sample.mov (0.10 MB / 5s)FAIL0.08 MB16 msOK
hevc.mov (HEVC video + AAC)FAIL0.12 MB4 msOK
huge5.mov (158.6 MB / 900s)FAIL13.88 MB378 msOK (476 ms)
audio.m4aOK0.08 MB3 msOK (no regression)
sample.mp4OK0.08 MB3 msOK (no regression)

The huge5.mov row is the one that mattered: 158.6 MB is the real-world mobile average. The old path read all of it into memory and still threw EncodingError; the new path produces a decoded 15-minute audio buffer in 854 ms end to end. Output was verified non-silent (peak > 0) and length-accurate (900.023s source → 900.1s decoded) — “it returned a buffer” is not the same as “it worked”.

Failing backwards, never sideways

Every step returns null the moment reality stops matching the assumptions, and the caller falls back to the original direct-decode path:

InputResult
Fragmented MP4 (empty_moov)null — sample tables live in moof, assumption broken
PCM audio track in a .movnull — only mp4a is handled
.webmnever enters this path
.mov with no audio tracknull — no soun track found

That is the rule that made this safe to ship: a module whose job is to rescue guaranteed failures must never make a working case worse. Every null above is a case that was going to run the old path anyway.

What this means if you are not writing a demuxer

Three practical takeaways:

  • A file that “does not work on iPhone” is usually a container problem, not a broken file. Your recording is fine. Re-exporting it as .mp4 from any editor will typically make it work in tools that have not fixed this.
  • File size is a bad predictor of failure. We used to warn users about large .mov files. After this fix that warning became a lie — size no longer correlates with any real risk, because only the audio bytes are ever moved. So we deleted it. A stale warning is worse than no warning.
  • You should not have to convert anything. Any tool that reads the audio track properly can take your .mov as it came off the camera roll.

Ours does. Drop a .mov in from your phone — it is decoded and transcribed locally, on your device, and the file is never uploaded.

More guides