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.
Series: Building a CCTV App for macOS
- Building CCTV RTSP Live: A macOS App for Monitoring IP Cameras
- The Math Behind Cursor-Centered Zoom
- Building a Recording Engine with FFmpeg on macOS
- Fixing H265 Stream Freeze on Tuya Cameras
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:
/usr/local/bin/ffmpeg(Homebrew Intel)/opt/homebrew/bin/ffmpeg(Homebrew Apple Silicon)PATHlookup 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.
Series: Building a CCTV App for macOS
- Building CCTV RTSP Live: A macOS App for Monitoring IP Cameras
- The Math Behind Cursor-Centered Zoom
- Building a Recording Engine with FFmpeg on macOS
- Fixing H265 Stream Freeze on Tuya Cameras
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