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
| Approach | Best for | Tradeoff |
|---|---|---|
| FFmpeg via os/exec | Self-hosted services, full control over flags | ~150MB FFmpeg install |
| u2takey/ffmpeg-go | Complex filter chains, typed argument building | Still requires FFmpeg installed |
| ChangeThisFile API | Lambda, distroless, multi-tenant SaaS | Network 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.