The trick with MOV-to-MP4: most modern MOVs already contain H.264 video, so MP4 conversion is just a container swap — instant, no quality loss. Re-encoding is only needed for ProRes, DNxHD, or other pro codecs. Detecting which case you're in is the difference between a 1-second conversion and a 5-minute one.

Method 1: ffmpeg-python with stream-copy (the fast path)

If the MOV's video and audio are already in MP4-compatible codecs (H.264 + AAC), -c copy just rewraps the streams — no re-encoding, no quality loss, takes seconds.

pip install ffmpeg-python
apt install ffmpeg  # or brew install ffmpeg
import ffmpeg
import subprocess
import json

def get_codecs(in_path: str) -> tuple[str, str]:
    """Return (video_codec, audio_codec) for a media file."""
    probe = ffmpeg.probe(in_path)
    video = next((s for s in probe["streams"] if s["codec_type"] == "video"), None)
    audio = next((s for s in probe["streams"] if s["codec_type"] == "audio"), None)
    return (video["codec_name"] if video else "", audio["codec_name"] if audio else "")

def mov_to_mp4(in_path: str, out_path: str) -> None:
    vcodec, acodec = get_codecs(in_path)
    if vcodec in ("h264", "hevc") and acodec in ("aac", "mp3"):
        # Fast path: stream copy
        (
            ffmpeg
            .input(in_path)
            .output(out_path, c="copy", movflags="+faststart")
            .overwrite_output()
            .run(quiet=True)
        )
    else:
        # Slow path: re-encode
        (
            ffmpeg
            .input(in_path)
            .output(
                out_path,
                vcodec="libx264",
                acodec="aac",
                crf=23,
                preset="medium",
                movflags="+faststart",
            )
            .overwrite_output()
            .run(quiet=True)
        )

mov_to_mp4("recording.mov", "recording.mp4")

Three things to know:

  • movflags=+faststart moves the MP4 metadata to the beginning so the file starts playing before fully downloaded. Always use this for web video.
  • CRF (Constant Rate Factor) 18-28 — 18 is visually lossless, 23 is web-standard, 28 is small file. Lower = better quality.
  • preset ultrafast → veryslow — slower presets give smaller files at the same quality. 'medium' is a good default.

Method 2: moviepy (ergonomic, always re-encodes)

moviepy wraps FFmpeg with a Pythonic API. Easier than ffmpeg-python for compositional work but always re-encodes — slower than stream-copy.

pip install moviepy
apt install ffmpeg
from moviepy.editor import VideoFileClip

def mov_to_mp4(in_path: str, out_path: str, crf: int = 23) -> None:
    with VideoFileClip(in_path) as clip:
        clip.write_videofile(
            out_path,
            codec="libx264",
            audio_codec="aac",
            ffmpeg_params=["-crf", str(crf), "-movflags", "+faststart"],
            preset="medium",
            threads=4,
        )

mov_to_mp4("recording.mov", "recording.mp4")

moviepy is the right choice when you need to trim, concatenate, add audio, overlay text, etc. For pure container/codec conversion, ffmpeg-python is faster.

Method 3: ChangeThisFile API (no FFmpeg)

If you don't want FFmpeg in your container, the API runs it server-side. Free tier covers 1,000 conversions/month.

import requests

API_KEY = "ctf_sk_your_key_here"

def mov_to_mp4(in_path: str, out_path: str) -> None:
    with open(in_path, "rb") as f:
        response = requests.post(
            "https://changethisfile.com/v1/convert",
            headers={"Authorization": f"Bearer {API_KEY}"},
            files={"file": f},
            data={"source": "mov", "target": "mp4"},
            timeout=300,  # videos can take a while
        )
    response.raise_for_status()
    with open(out_path, "wb") as out:
        out.write(response.content)

mov_to_mp4("recording.mov", "recording.mp4")

The API auto-detects whether stream-copy is possible. For H.264 MOVs, conversion is near-instant. For ProRes or other pro codecs, it re-encodes server-side. Either way, output is web-ready (faststart, AAC audio).

When to use each

ApproachBest forTradeoff
ffmpeg-python (stream-copy path)Most cases — instant for H.264 MOVsNeed to detect codec first
moviepyWhen you need to edit (trim, overlay, concat) tooAlways re-encodes — slower
ChangeThisFile APINo FFmpeg in your environmentNetwork call, file size limit (25MB free)

Production tips

  • Always probe first. Check video/audio codecs with ffprobe (or ffmpeg.probe) before deciding to re-encode. Stream-copy is 100x faster when it works.
  • Use +faststart for web delivery. Without faststart, browsers wait for the entire MP4 to download before playing. With it, playback starts after the first chunk.
  • CRF 23 is the right web default. Visually indistinguishable from source for most content. Drop to 20 for archival; bump to 28 for tiny files.
  • preset='medium' is the right default. 'fast' for live processing; 'slow' or 'veryslow' for once-and-done archival. Each preset roughly doubles encode time and saves ~15% file size.
  • Bound concurrency. FFmpeg pegs all cores during encoding. For batch jobs, run one or two conversions at a time per host.
  • Set a long timeout. A 1GB video can take 5-10 minutes to re-encode. 300s minimum on the API client.

For most MOV files (iPhone recordings, screen captures), stream-copy converts in seconds without quality loss. For ProRes or pro codecs, re-encode at CRF 23. For environments without FFmpeg, the API. Free tier covers 1,000 conversions/month.