Every converter website works the same way: you upload your video to somebody's server farm, wait for the transfer, wait for the conversion, download the result — and trust that your footage wasn't kept. For an 800 MB home video, that's a lot of trust.
I kept wondering: the file is already on my machine, the output has to end up on my machine… why is anything leaving it?
So I built movconverter, a TypeScript library that converts MOV → MP4 entirely inside the browser. No server, no upload, no size limit invented by someone's storage bill. I also deployed it as a free web tool at movtomp4.net — open the page, drop in a file, and everything happens locally in your tab.
The surprising part: for the most common case (iPhone/Mac footage), the "conversion" is bit-identical and finishes in about the time it takes to read the file from disk. No ffmpeg.wasm, no decoding, no WebCodecs even. Here's how.
TL;DR — use it in your project
import { convertMovToMp4, canConvert } from 'movconverter'
// Ask what would happen first — reads only metadata, costs nothing
const plan = await canConvert(file)
plan.method // 'remux' | 'audio-transcode' | 'full-transcode'
plan.dropped // tracks MP4 can't carry (timecode…) — reported, not silently lost
const { blob, method } = await convertMovToMp4(file, {
onProgress: (p) => console.log(`${Math.round(p * 100)}%`),
})
// method === 'remux' means the output media is bit-identical to the input
Zero runtime dependencies, and a worker entry point if you don't want to touch the main thread (more on that below).
Why you don't need ffmpeg for this
QuickTime File Format shipped in 1991. MP4 was standardized a decade later from that same spec — it's a direct descendant. Both files are trees of boxes:
[ftyp] [moov] [mdat]
│
└── trak → mdia → minf → stbl
├── stsd (which codecs?)
├── stts (timestamps)
├── stss (keyframes)
├── stsc (sample → chunk mapping)
├── stsz (sample sizes)
└── stco (byte offsets into mdat)
The expensive part of a video file is the encoded streams, not the container. Re-encoding H.264 is slow and lossy. Rewriting a few hundred KB of index tables is neither. So when the codecs inside a MOV are already MP4-compatible — H.264/HEVC video + AAC audio, which is exactly what iPhones and Macs produce — "conversion" is a metadata rewrite, and the media bytes never change.
Detect first, convert second
The library doesn't ask you to pick a path. It reads the stsd box of each track and picks the cheapest viable route:
| Video | Audio | Path | Typical source |
|---|---|---|---|
avc1 / hvc1
|
mp4a (AAC) |
pure remux | iPhone, Mac screen recordings |
| H.264 | PCM (sowt, twos, lpcm…) |
copy video, re-encode audio via WebCodecs | cameras (Canon, Nikon, Fujifilm…) |
| ProRes / MJPEG | anything | full transcode (lazy-loaded wasm) | pro workflows, legacy cameras |
tmcd timecode & co |
— | dropped and reported in the plan | editing software |
That last row matters more than it looks: converters that silently drop tracks are how you end up with a "successful" file that lost something you cared about. The decision object is exposed as a public preflight API, which is what lets movtomp4.net tell you before starting whether your file will be lossless-in-seconds or needs the slower audio route.
The remux: metadata surgery
The remux path has three interesting moves.
1. Rebuild moov without breaking the picture. Drop the boxes MP4 rejects (timecode tracks, Apple-private user-data atoms) — but two things must survive or the output is visibly wrong:
- the
tkhdrotation matrix. iPhone portrait video is stored landscape with a rotation transform; lose it and your video lies on its side. - the
colrbox. Drop it and an HDR recording comes out washed out.
2. Re-tag HEVC. MOV files write HEVC sample entries as hev1; MP4 players expect hvc1. Writing hev1 into an MP4 breaks playback in Safari and QuickTime. The fix is comically small — four bytes flipped in place:
/** Rewrite hev1 sample entries to hvc1 (required for Safari/QuickTime playback). */
export function retagHevc(moovBytes: Uint8Array, kept: KeptTrack): void {
for (const entry of kept.track.stsdEntries) {
if (entry.format !== 'hev1') continue
const formatPos = entry.posInMoov - kept.track.trakStart + kept.newTrakStart + 4
moovBytes.set([0x68, 0x76, 0x63, 0x31], formatPos) // 'hvc1'
}
}
3. Faststart without moving gigabytes. Web players can't start streaming until they've read the index, so moov goes at the front of the output. The trick: every sample table is a fixed-length structure, so the size of the new moov is computable before anything is written — which means every chunk offset shifts by one computable delta. The whole offset-patching step is a lookup:
function relocate(segments: RelocationSegment[], offset: number): number {
for (const seg of segments) {
if (offset >= seg.oldStart && offset < seg.oldEnd) {
return offset + seg.delta
}
}
throw new ConversionError(
'chunk offset does not fall inside any copied data box; corrupt sample table?',
)
}
The output is verified lossless against ffmpeg in the test suite: demux both files, compare streams bit for bit.
Constant memory: why 4 GB files don't blow the tab
One rule shaped the whole codebase: no code path may read the entire input file into an ArrayBuffer. The box reader walks the file by reading 8-byte headers through Blob.slice(), then either descends or skips. Only moov is ever actually parsed — typically a few MB even for a two-hour 4K recording. mdat is recorded as byte ranges and streamed out with Blob.slice() → WritableStream. Sketch (the real one also handles 64-bit sizes and co64):
async function* iterBoxes(blob: Blob, offset: number): AsyncGenerator<BoxHeader> {
while (offset < blob.size) {
const head = new DataView(await blob.slice(offset, offset + 8).arrayBuffer())
const size = head.getUint32(0)
const type = String.fromCharCode(head.getUint8(4), head.getUint8(5),
head.getUint8(6), head.getUint8(7))
yield { type, start: offset, size, end: offset + size }
offset += size
}
}
Memory usage is constant regardless of file size.
Tier 2: when the audio has to change
Camera footage is usually H.264 video + PCM audio, and PCM is what MP4 handles poorly. The neat part: PCM needs no decoder — the samples are already raw. Pull them out of mdat via the sample tables, normalize the byte order (sowt is little-endian, twos big-endian), and feed the browser's native AudioEncoder:
/** Map a QuickTime sound format + description to a concrete PCM layout. */
export function resolvePcmKind(format: string, audio: AudioSampleInfo): PcmKind {
switch (format) {
case 'raw ': return 'u8'
case 'sowt': return audio.bitsPerSample === 8 ? 's8' : 's16le'
// … twos → big-endian variants, in24 → s24be, lpcm → f32 per flags
}
}
One pitfall worth knowing before you try this yourself: the first AAC frame contains encoder priming samples that aren't real audio. Without an elst edit list to compensate, the audio slowly drifts out of sync — a bug that only shows up on longer recordings and is miserable to debug.
Off the main thread
All of the above runs in a Web Worker, so even a large conversion never freezes the page, and it's cancellable with a plain AbortController:
import { convertInWorker } from 'movconverter'
const worker = new Worker(new URL('movconverter/worker', import.meta.url), {
type: 'module',
})
const { blob, method } = await convertInWorker(worker, file, { onProgress })
Try it, or ship it
- As a user: movtomp4.net — the converter is just JavaScript already loaded in your tab, so there's no upload wait and no file-size ceiling.
-
As a developer:
npm install movconverter, MIT licensed:
zikcheng
/
movconverter
Browser-only MOV → MP4 conversion.
On the roadmap: the wasm tier for ProRes/MJPEG, segmented transcoding to break past the 2 GB wasm memory wall (the codecs that need wasm are all-intra, which conveniently makes cutting the file at any frame safe), and a frame-level pipeline that decodes with a trimmed libavcodec and re-encodes through hardware via VideoEncoder.
The biggest lesson from building this: sometimes the best conversion is realizing you don't need one. Look inside the file before reaching for a transcoder — if the codecs are already compatible, ship the bytes and rewrite the envelope.
Have you built anything with WebCodecs yet? The audio path barely scratches the surface of what AudioEncoder/VideoEncoder can do in the browser now — curious what you'd reach for it with.
Top comments (0)