In short
- We started with SCRecordingOutput, then replaced it with our own AVAssetWriters: one file per source, all timed on one shared clock.
- The camera bubble, cursor and zoom are drawn at export by a custom AVVideoCompositing compositor, so they stay editable after recording.
- On an M1 the H.264 hardware encoder tops out around 85 fps at 2880×1800: export speed is set by the encoder, not by drawing.
- Most of our hardest bugs were not in capture code: permissions tied to signatures, a locked screen, a SwiftUI player crash.
ScreenCaptureKit is the macOS framework for capturing the screen, and it does that one job well: it hands you frames and audio. Files, timing, the camera, the export and the permissions are yours to build. Here is what we learned building JustRec, a native menu bar recorder, on top of it.
The short version: we started with Apple’s built-in recording output, moved to our own writers that save each source to its own file on one shared clock, and now draw everything else at export. The hardest bugs had nothing to do with capture.
Version one
SCRecordingOutput
One stream, one finished MP4. The fastest way to a working prototype.
What broke
One file wasn't enough
The camera, an editable zoom and a clean voice track for subtitles didn't fit in a single recorded file.
Version two
Separate writers on one clock
Screen, camera, system audio and microphone each go to their own AVAssetWriter, timed by one shared capture clock.
Version three
A compositor at export
The bubble, cursor, clicks and zoom are drawn with Core Image when you press stop, so they stay editable.
Why we require macOS 15
JustRec runs on macOS 15 Sequoia or later. That wasn’t a marketing choice. In macOS 15, ScreenCaptureKit gained microphone capture: captureMicrophone and microphoneCaptureDeviceID on SCStreamConfiguration, plus a .microphone output type on the stream. SCRecordingOutput arrived in the same release.
Microphone capture inside the stream means system audio and your voice come from the same source, with timestamps from the same clock. On older systems you’d run a separate audio capture and line it up yourself. Dropping macOS 14 costs us some potential users and saves us a whole class of sync bugs.
let config = SCStreamConfiguration()
config.minimumFrameInterval = CMTime(value: 1, timescale: 30)
config.capturesAudio = true // system audio
config.excludesCurrentProcessAudio = true // not our own sounds
config.captureMicrophone = true // macOS 15
config.microphoneCaptureDeviceID = microphoneID // macOS 15
Version one: SCRecordingOutput
Our first prototype used SCRecordingOutput. You attach it to the stream, give it a file URL, and you get an MP4 when you stop. For a plain screen recording it’s the fastest route to a working app, and we’d recommend it if that’s all you need.
It stopped fitting once we knew what the product had to do:
- The camera comes from a different place. The camera bubble is an
AVCaptureSession(built-in camera, external webcam or an iPhone through Continuity Camera), not part of the screen stream. - We wanted the bubble out of the screen capture. If the bubble window is burned into the screen frames, an auto zoom magnifies it too. We wanted to zoom on the screen and keep the bubble the same size.
- Subtitles need a clean voice track. Transcription works best on the microphone alone, without system sounds mixed in, and it can start before the final video exists.
- Effects have to stay editable. Auto zoom, click highlights and the cursor can be changed after recording, so they can’t be baked in while you record.
One finished file couldn’t give us any of that, so we wrote our own writers.
| SCRecordingOutput (version one) | Our own writers (now) | |
|---|---|---|
| Output | One finished MP4 | One file per source, plus events.json |
Camera from an AVCaptureSession |
✗ not part of the stream | ✓ its own file, on the same clock |
| Zoom without enlarging the bubble | ✗ bubble burned into the frames | ✓ bubble drawn after the zoom |
| Voice track for subtitles | ✗ waits for the finished file | ✓ microphone on its own track |
| Effects editable after recording | ✗ | ✓ rendered again from the same files |
| Code to write and maintain | Very little | Writers, clock, compositor, exporter |
Version two: separate writers on one clock
Each source now goes to its own AVAssetWriter: screen.mp4, camera.mp4, a system audio track and a microphone track, plus an events.json with the mouse position (sampled 60 times a second), clicks, the bubble’s position and shape, and the cursor images. All of it lives in a project folder; the MP4 you share is rendered from it.
The rule that made this work: every sample gets its time from one shared capture clock, based on the host time clock. The clock also handles pause. Frames that arrive during a pause are dropped, and the length of every past pause is subtracted from later timestamps, so the files have no gaps.
func outputTime(forHost time: CMTime) -> CMTime? {
guard let start, time >= start else { return nil }
var output = time - start
for pause in pauses {
guard time >= pause.start else { break }
guard let end = pause.end, time >= end else { return nil } // paused: drop
output = output - (end - pause.start)
}
return output
}
The camera runs on its own capture session with its own clock, so its timestamps are converted to host time before they reach the shared clock:
var time = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
if let source = session.synchronizationClock {
time = CMSyncConvertTime(time, from: source, to: CMClockGetHostTimeClock())
}
A few details that cost us time:
- Files start late. The camera might deliver its first frame half a second after the screen. Each file records its real start time, and at export it’s placed at that offset, never at zero. For video, the first frame is held until the file really begins, so there’s no black flash.
- Audio formats change mid-recording. Switch microphones while recording and the sample format can change. Instead of feeding the old writer a new format, we close the file and open
mic-2.m4aat the current time. The exporter puts the pieces back in order. - Only write complete frames. ScreenCaptureKit attaches a status to every frame. We only write frames marked
.completeand skip the rest. - Close the last frame properly. If nothing moves on screen, the last frame can arrive long before you press stop. We append a copy of it just before the end time, so the video lasts as long as the recording did.
Version three: a compositor at export
When you press stop, we build an AVMutableComposition from the project files and render it with a custom AVVideoCompositing compositor. For every output frame it receives the screen frame and the camera frame, and draws the result with Core Image: the background and padding, click pulses, a redrawn cursor, the virtual camera for auto zoom, and finally the camera bubble, masked to a circle, a square or 16:9, with a border and a shadow.
Audio goes through a reader/writer exporter: an AVAssetReaderAudioMixOutput mixes system audio and microphone into one 48 kHz stereo stream, which is encoded to AAC next to the H.264 video. Because the zoom and the bubble are drawn here, changing a zoom later just means rendering again from the same files.
The same composition also feeds the preview. While the export is still running, the player shows the composition live, so you can watch the video right after you stop.
We started by exporting HEVC and switched to H.264. The files are a bit bigger, but they play in any browser on any client’s computer, which matters more for a share link than a few megabytes.
The encoder is the bottleneck
We expected drawing to be the slow part. It isn’t. On an M1, the H.264 hardware encoder tops out around 85 frames per second at 2880×1800. A ten-minute recording at 30 fps is 18,000 frames, so even in the best case the export of a Retina video takes about three and a half minutes.
The proof that drawing isn’t the problem: when we added auto zoom with motion blur and frame blending during zoom changes, export speed didn’t change. Core Image on the GPU keeps up easily; the encoder sets the pace.
So the levers are about the encoder and about waiting, not about drawing code. The biggest one for users is the resolution switch in .
| Lever | Effect |
|---|---|
| Full HD instead of Retina | Fewer pixels per frame for the encoder |
| 30 fps capture | Half the frames of 60 fps |
| Showing the composition live in the preview | You don’t have to wait to watch |
| Uploading only after export | One file, one upload, no re-encoding on the server |
Pitfalls that weren’t about capture
Screen recording permission that kept coming back
macOS kept asking for screen recording permission even after we’d granted it. The cause: an old ad-hoc signed debug copy with the same bundle id was still on disk and held the grant. The system saw two apps with one name and different signatures.
The fix was to delete the extra copies and reset the permission:
tccutil reset ScreenCapture dev.wearenube.rec
The lesson is about signing, not ScreenCaptureKit: sign every build the same way and never ship ad-hoc builds. If you’re a user stuck in the same loop, our guide to fixing screen recording permission on Mac covers the steps without the terminal.
A locked Mac records the wallpaper
If the screen locks while you record, the video shows only the wallpaper. Nothing crashes and no error is raised: you just get minutes of desktop background. Keep the Mac awake and don’t let it lock while you record. It’s the kind of failure you only notice when the client does.
SwiftUI’s VideoPlayer crashed
Our player views first used SwiftUI’s VideoPlayer. Built with the Xcode 26 SDK, it crashed on macOS 15. We wrapped AppKit’s AVPlayerView instead and the crashes stopped:
struct PlayerView: NSViewRepresentable {
let player: AVPlayer
func makeNSView(context: Context) -> AVPlayerView {
let view = AVPlayerView()
view.player = player
view.controlsStyle = .floating
return view
}
func updateNSView(_ view: AVPlayerView, context: Context) {
if view.player !== player { view.player = player }
}
}
Running out of disk
Separate files plus an export means a recording briefly takes more than twice its final size. JustRec won’t start recording with less than 2 GB free, and stops and saves if free space drops under 500 MB during a recording.
What we’d do again
If you're building on ScreenCaptureKit
- Pick your minimum macOS by the APIs you need, not the other way round.
- Put every source on one clock from day one; retrofitting sync is painful.
- Keep raw sources and render at export if anything must stay editable.
- Measure the encoder before optimizing your drawing code.
- Sign consistently, even in development, and delete old builds.
- Test with the screen locked, a full disk and a microphone that changes mid-recording.
We’d also start with SCRecordingOutput again. It got us a working prototype quickly and showed us exactly which limits mattered for our product.
The clean microphone track has a second life: it’s what the subtitles are made from. That story is in how we run Whisper on the Mac.
For what happens after upload, see what it costs to host client videos on Cloudflare R2. For the user-facing side of the camera bubble, our guide to recording your screen and camera at the same time shows it in practice, and the features list shows what all this machinery adds up to.
Sources
- Apple Developer: ScreenCaptureKit, checked November 2026.
- Apple Developer: SCStreamConfiguration.captureMicrophone (macOS 15.0), checked November 2026.
- Apple Developer: SCStreamConfiguration.microphoneCaptureDeviceID (macOS 15.0), checked November 2026.
- Apple Developer: SCRecordingOutput (macOS 15.0), checked November 2026.
- Apple Developer: SCFrameStatus, checked November 2026.
- Apple Developer: AVVideoCompositing, checked November 2026.
- Apple Developer: AVAssetReaderAudioMixOutput, checked November 2026.
- Apple Developer: AVPlayerView, checked November 2026.
Frequently asked questions
What is ScreenCaptureKit?
ScreenCaptureKit is Apple's framework for capturing screen content and audio on macOS. You choose displays, windows or apps with a content filter, and it delivers video and audio as sample buffers your app can process or write to a file.
Which macOS version do you need for microphone capture in ScreenCaptureKit?
macOS 15. The captureMicrophone and microphoneCaptureDeviceID options of SCStreamConfiguration, the microphone output type and SCRecordingOutput all arrived in macOS 15.0, which is why JustRec requires macOS 15 Sequoia or later.
Should I use SCRecordingOutput or AVAssetWriter?
SCRecordingOutput is the quickest way to get one finished file. If you need separate tracks, a camera from another capture session, pause handling or effects you can edit after recording, writing the sample buffers yourself with AVAssetWriter gives you that control.
Why does a ScreenCaptureKit recording show only the wallpaper?
In our testing this happened when the Mac was locked during the recording: the video showed the wallpaper instead of the apps. Keep the Mac awake, and don't let it lock, while you record.
Why does macOS keep asking for screen recording permission during development?
The permission is tied to the app's identity and signature. If an old copy with the same bundle id but a different signature holds the grant, the new build keeps asking. Delete extra copies, reset the permission with tccutil and sign every build the same way.



