Most MOVs are already H.264 — the conversion to MP4 is just a container swap, not a re-encode. Detecting that case in Go means probing with ffprobe first. The savings are huge: stream-copy is ~100x faster than re-encoding.

Method 1: ffprobe + ffmpeg with stream-copy detection

The right approach: probe codecs with ffprobe, then choose stream-copy or re-encode.

apt install ffmpeg  # ffmpeg + ffprobe in one package
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "os"
    "os/exec"
    "time"
)

type probeOutput struct {
    Streams []struct {
        CodecType string `json:"codec_type"`
        CodecName string `json:"codec_name"`
    } `json:"streams"`
}

func probeCodecs(ctx context.Context, inPath string) (videoCodec, audioCodec string, err error) {
    cmd := exec.CommandContext(ctx, "ffprobe",
        "-v", "error",
        "-show_streams",
        "-of", "json",
        inPath,
    )
    out, err := cmd.Output()
    if err != nil {
        return "", "", fmt.Errorf("ffprobe: %w", err)
    }
    var p probeOutput
    if err := json.Unmarshal(out, &p); err != nil {
        return "", "", err
    }
    for _, s := range p.Streams {
        switch s.CodecType {
        case "video":
            videoCodec = s.CodecName
        case "audio":
            audioCodec = s.CodecName
        }
    }
    return videoCodec, audioCodec, nil
}

func movToMP4(ctx context.Context, inPath, outPath string) error {
    vcodec, acodec, err := probeCodecs(ctx, inPath)
    if err != nil {
        return err
    }

    var cmd *exec.Cmd
    if (vcodec == "h264" || vcodec == "hevc") && (acodec == "aac" || acodec == "mp3") {
        // Fast path: stream copy
        cmd = exec.CommandContext(ctx, "ffmpeg", "-y",
            "-i", inPath,
            "-c", "copy",
            "-movflags", "+faststart",
            outPath,
        )
    } else {
        // Slow path: re-encode
        cmd = exec.CommandContext(ctx, "ffmpeg", "-y",
            "-i", inPath,
            "-c:v", "libx264",
            "-c:a", "aac",
            "-crf", "23",
            "-preset", "medium",
            "-movflags", "+faststart",
            outPath,
        )
    }

    if out, err := cmd.CombinedOutput(); err != nil {
        return fmt.Errorf("ffmpeg: %w (%s)", err, out)
    }
    return nil
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
    defer cancel()

    if err := movToMP4(ctx, "recording.mov", "recording.mp4"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Three things to know:

  • +faststart is mandatory for web delivery. Moves MP4 metadata to the start so playback begins before full download.
  • CRF 23, preset='medium' are the right re-encode defaults for web.
  • Set timeout to 300s minimum. A 1GB video re-encode can take 5-10 minutes.

Method 2: u2takey/ffmpeg-go (typed Go wrapper)

If you want a fluent API instead of constructing arg slices, u2takey/ffmpeg-go provides typed builders.

go get github.com/u2takey/ffmpeg-go
package main

import (
    "fmt"
    "os"

    ffmpeg "github.com/u2takey/ffmpeg-go"
)

func movToMP4(inPath, outPath string) error {
    return ffmpeg.Input(inPath).
        Output(outPath, ffmpeg.KwArgs{
            "c":        "copy",
            "movflags": "+faststart",
        }).
        OverWriteOutput().
        Run()
}

func main() {
    if err := movToMP4("recording.mov", "recording.mp4"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

This is essentially a Go-ergonomic version of method 1. It still requires FFmpeg installed and shells out under the hood. Useful when you're chaining many filters; overkill for a one-shot conversion.

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.

package main

import (
    "bytes"
    "fmt"
    "io"
    "mime/multipart"
    "net/http"
    "os"
    "time"
)

const apiKey = "ctf_sk_your_key_here"

func movToMP4(inPath, outPath string) error {
    body := &bytes.Buffer{}
    w := multipart.NewWriter(body)

    f, err := os.Open(inPath)
    if err != nil {
        return err
    }
    defer f.Close()

    fw, err := w.CreateFormFile("file", "input.mov")
    if err != nil {
        return err
    }
    if _, err := io.Copy(fw, f); err != nil {
        return err
    }
    _ = w.WriteField("source", "mov")
    _ = w.WriteField("target", "mp4")
    _ = w.Close()

    req, err := http.NewRequest("POST", "https://changethisfile.com/v1/convert", body)
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+apiKey)
    req.Header.Set("Content-Type", w.FormDataContentType())

    client := &http.Client{Timeout: 300 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        msg, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("api %d: %s", resp.StatusCode, msg)
    }

    out, err := os.Create(outPath)
    if err != nil {
        return err
    }
    defer out.Close()
    _, err = io.Copy(out, resp.Body)
    return err
}

func main() {
    if err := movToMP4("recording.mov", "recording.mp4"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The API auto-detects whether stream-copy is possible. For H.264 MOVs, conversion is near-instant.

When to use each

ApproachBest forTradeoff
FFmpeg via os/execSelf-hosted services, full control over flags~150MB FFmpeg install
u2takey/ffmpeg-goComplex filter chains, typed argument buildingStill requires FFmpeg installed
ChangeThisFile APILambda, distroless, multi-tenant SaaSNetwork call, file size limit (25MB free)

Production tips

  • Always probe first. Stream-copy is 100x faster when applicable. Skipping the probe and always re-encoding wastes CPU.
  • Bound concurrency strictly. FFmpeg pegs all cores during encoding. For batch jobs on a server, run one or two at a time. The API handles concurrency server-side.
  • Use CommandContext with a 300s+ timeout. Long videos take real time to re-encode. 300s is a reasonable bound for files under 1GB; bump to 1800s for archive jobs.
  • Don't capture stderr in production. FFmpeg writes its progress meter to stderr — that's a lot of bytes. Use cmd.Output() (stdout only) and let stderr go to /dev/null.
  • Watch zombie processes. If your service exits while FFmpeg is running, the child can be orphaned. Use SetPgid + Killpg to cleanly terminate the entire process group.

For most MOV files, the stream-copy detection above gives near-instant conversion. For environments without FFmpeg, the API. Free tier covers 1,000 conversions/month.