← Back to Writings

Building a Recording Engine with FFmpeg on macOS

Why VLCKit's built-in recording failed for RTSP live streams, and how I replaced it with an FFmpeg subprocess that actually works.

June 2, 2025 Updated May 28, 2026
SwiftmacOSFFmpegRTSPRecordingVLCKitSubprocessmacOS SandboxCCTV App

Recording was supposed to be a simple feature. Add a button, call startRecording(), done. It took me a week to ship it properly.

The VLCKit Dead End

VLCKit has a startRecording(toFile:) method. I called it. It returned false. Every time, for every RTSP stream I tested.

After digging through the VLCKit source and a few GitHub issues, the picture became clear: startRecording on a live RTSP stream is unreliable in vlckit-spm 3.6.0. It works for local files. For live streams it silently fails. There’s no error callback, no explanation - just false.

I could have tried to work around it, but at some point you have to accept that a tool isn’t designed for your use case. VLCKit is great for playback. For recording live RTSP streams, I needed something else.

FFmpeg as a Subprocess

The approach I landed on: spawn ffmpeg as a subprocess using Swift’s Process API. FFmpeg handles the RTSP connection independently from VLC, copies the stream to a file, and I manage the process lifecycle from Swift.

let process = Process()
process.executableURL = ffmpegURL
process.arguments = [
    "-rtsp_transport", "tcp",
    "-i", streamURL,
    "-c:v", "copy",
    "-c:a", "aac",
    "-movflags", "+faststart",
    outputPath
]

The first challenge was finding ffmpeg. I can’t assume it’s in PATH when launched from a sandboxed app. I check a few known locations in order:

  1. /usr/local/bin/ffmpeg (Homebrew Intel)
  2. /opt/homebrew/bin/ffmpeg (Homebrew Apple Silicon)
  3. PATH lookup as fallback

If none are found, I show a setup prompt pointing to the Homebrew install command.

H265 and the hev1 vs hvc1 Problem

H265 video in MP4/MOV containers uses a codec tag. There are two: hev1 and hvc1. QuickTime Player only accepts hvc1. If you record an H265 stream and the tag comes out as hev1, the file plays fine in VLC but QuickTime refuses to open it.

The fix is to detect the codec before recording and set the tag explicitly:

// ffprobe to detect codec
let codec = try await probeCodec(url: streamURL)

if codec == "hevc" {
    arguments += ["-tag:v", "hvc1"]
}

I run ffprobe once per URL and cache the result. Subsequent recordings of the same stream skip the probe. The cache is keyed by URL and cleared when the app restarts.

private var codecCache: [String: String] = [:]

func probeCodec(url: String) async throws -> String {
    if let cached = codecCache[url] { return cached }
    // run ffprobe...
    codecCache[url] = result
    return result
}

Audio: Always Transcode to AAC

Most RTSP cameras encode audio as G.711 (PCMU/PCMA) or G.726. Neither is compatible with MP4/MOV containers without transcoding. Rather than trying to detect and handle each case, I always transcode audio to AAC:

"-c:a", "aac", "-b:a", "128k"

The overhead is minimal. AAC at 128k is fine for security camera audio, and it guarantees the output file opens correctly in QuickTime and iMovie.

Graceful Stop

Killing ffmpeg with SIGKILL corrupts the output file - the MP4 container doesn’t get finalized. The right signal is SIGINT, which tells ffmpeg to flush and close cleanly.

func stopRecording() {
    process.interrupt() // sends SIGINT
    
    // Give ffmpeg 3 seconds to finalize
    DispatchQueue.global().asyncAfter(deadline: .now() + 3) { [weak self] in
        if self?.process.isRunning == true {
            self?.process.terminate() // SIGTERM if still running
        }
    }
}

Three seconds is enough for ffmpeg to write the moov atom and close the file. In practice it usually finishes in under a second.

What I’d Do Differently

Bundling ffmpeg inside the app would be cleaner than requiring a Homebrew install. The binary is about 80MB though, which feels heavy for a utility app. For now the external dependency is acceptable - anyone running RTSP cameras on a Mac probably has Homebrew already.

The subprocess approach also means I can’t show recording progress from within the app without parsing ffmpeg’s stderr output. That’s a future problem. For now, a simple “recording” indicator with elapsed time is enough.

The lesson: when a framework method silently returns false, don’t spend days trying to make it work. Sometimes the right move is to reach for a lower-level tool that does exactly what you need.

Building something similar?

I take on macOS engineering work, especially around media, networking, and resilient subprocess pipelines. If you're stuck on a similar problem, let's talk.

Hire me