Kurz gesagt
- Wir haben mit SCRecordingOutput angefangen und es dann durch eigene AVAssetWriter ersetzt: eine Datei pro Quelle, alle auf einer gemeinsamen Uhr.
- Kamerablase, Cursor und Zoom zeichnet beim Export ein eigener AVVideoCompositing-Compositor, so bleiben sie nach der Aufnahme bearbeitbar.
- Auf einem M1 schafft der H.264-Hardware-Encoder bei 2880×1800 etwa 85 fps: Die Exportgeschwindigkeit bestimmt der Encoder, nicht das Zeichnen.
- Die meisten unserer schwierigsten Bugs steckten nicht im Aufnahmecode: an Signaturen gebundene Berechtigungen, ein gesperrter Bildschirm, ein Absturz im SwiftUI-Player.
ScreenCaptureKit ist das macOS-Framework zum Aufnehmen des Bildschirms, und diese eine Aufgabe erledigt es gut: Es liefert dir Frames und Ton. Dateien, Timing, die Kamera, der Export und die Berechtigungen sind deine Sache. Hier ist, was wir gelernt haben, als wir JustRec, einen nativen Screen Recorder für die Menüleiste, darauf gebaut haben.
Die Kurzfassung: Wir haben mit Apples eingebauter Aufnahmeausgabe angefangen, sind auf eigene Writer umgestiegen, die jede Quelle in eine eigene Datei auf einer gemeinsamen Uhr schreiben, und zeichnen alles andere jetzt beim Export. Die schwierigsten Bugs hatten mit der Aufnahme gar nichts zu tun.
Version eins
SCRecordingOutput
Ein Stream, eine fertige MP4. Der schnellste Weg zu einem funktionierenden Prototyp.
Was nicht mehr ging
Eine Datei reichte nicht
Die Kamera, ein bearbeitbarer Zoom und eine saubere Sprachspur für Untertitel passten nicht in eine einzige aufgenommene Datei.
Version zwei
Getrennte Writer auf einer Uhr
Bildschirm, Kamera, Systemaudio und Mikrofon gehen jeweils an einen eigenen AVAssetWriter, getaktet von einer gemeinsamen Aufnahmeuhr.
Version drei
Ein Compositor beim Export
Kamerablase, Cursor, Klicks und Zoom werden mit Core Image gezeichnet, wenn du Stopp drückst, und bleiben so bearbeitbar.
Warum wir macOS 15 voraussetzen
JustRec läuft ab macOS 15 Sequoia. Das war keine Marketingentscheidung. Mit macOS 15 hat ScreenCaptureKit die Mikrofonaufnahme bekommen: captureMicrophone und microphoneCaptureDeviceID in SCStreamConfiguration, dazu einen Ausgabetyp .microphone im Stream. SCRecordingOutput kam in derselben Version.
Mikrofonaufnahme im Stream heißt: Systemaudio und deine Stimme kommen aus derselben Quelle, mit Zeitstempeln von derselben Uhr. Auf älteren Systemen müsstest du eine separate Audioaufnahme laufen lassen und sie selbst ausrichten. Der Verzicht auf macOS 14 kostet uns ein paar potenzielle Nutzer und erspart uns eine ganze Klasse von Sync-Bugs.
let config = SCStreamConfiguration()
config.minimumFrameInterval = CMTime(value: 1, timescale: 30)
config.capturesAudio = true // Systemaudio
config.excludesCurrentProcessAudio = true // nicht unsere eigenen Töne
config.captureMicrophone = true // macOS 15
config.microphoneCaptureDeviceID = microphoneID // macOS 15
Version eins: SCRecordingOutput
Unser erster Prototyp nutzte SCRecordingOutput. Du hängst es an den Stream, gibst ihm eine Datei-URL und bekommst beim Stoppen eine MP4. Für eine einfache Bildschirmaufnahme ist das der schnellste Weg zu einer funktionierenden App, und wir würden es empfehlen, wenn das alles ist, was du brauchst.
Es passte nicht mehr, sobald klar war, was das Produkt können muss:
- Die Kamera kommt von woanders. Die Kamerablase ist eine
AVCaptureSession(eingebaute Kamera, externe Webcam oder ein iPhone über die Integrationskamera), kein Teil des Bildschirm-Streams. - Wir wollten die Kamerablase aus der Bildschirmaufnahme heraushalten. Wenn das Fenster der Kamerablase in die Bildschirm-Frames eingebrannt ist, vergrößert ein Auto-Zoom es mit. Wir wollten auf den Bildschirm zoomen und die Kamerablase gleich groß lassen.
- Untertitel brauchen eine saubere Sprachspur. Transkription funktioniert am besten nur mit dem Mikrofon, ohne beigemischte Systemtöne, und sie kann starten, bevor das fertige Video existiert.
- Effekte müssen bearbeitbar bleiben. Auto-Zoom, Klick-Hervorhebungen und Cursor lassen sich nach der Aufnahme ändern, sie dürfen also nicht schon beim Aufnehmen eingebacken werden.
Eine fertige Datei konnte uns nichts davon geben, also haben wir eigene Writer geschrieben.
| SCRecordingOutput (Version eins) | Eigene Writer (heute) | |
|---|---|---|
| Ausgabe | Eine fertige MP4 | Eine Datei pro Quelle, plus events.json |
Kamera aus einer AVCaptureSession |
✗ nicht Teil des Streams | ✓ eigene Datei, auf derselben Uhr |
| Zoom, ohne die Kamerablase zu vergrößern | ✗ Kamerablase in die Frames eingebrannt | ✓ Kamerablase nach dem Zoom gezeichnet |
| Sprachspur für Untertitel | ✗ wartet auf die fertige Datei | ✓ Mikrofon auf eigener Spur |
| Effekte nach der Aufnahme bearbeitbar | ✗ | ✓ aus denselben Dateien neu gerendert |
| Code, der geschrieben und gepflegt werden muss | Sehr wenig | Writer, Uhr, Compositor, Exporter |
Version zwei: getrennte Writer auf einer Uhr
Jede Quelle geht jetzt an einen eigenen AVAssetWriter: screen.mp4, camera.mp4, eine Spur für Systemaudio und eine fürs Mikrofon, dazu eine events.json mit der Mausposition (60-mal pro Sekunde abgetastet), Klicks, Position und Form der Kamerablase und den Cursor-Bildern. Alles liegt in einem Projektordner, die MP4, die du teilst, wird daraus gerendert.
Die Regel, mit der das funktioniert: Jedes Sample bekommt seine Zeit von einer gemeinsamen Aufnahmeuhr, basierend auf der Host-Uhr. Die Uhr kümmert sich auch um Pausen. Frames, die während einer Pause kommen, werden verworfen, und die Länge jeder vergangenen Pause wird von späteren Zeitstempeln abgezogen, sodass die Dateien keine Lücken haben.
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 } // pausiert: verwerfen
output = output - (end - pause.start)
}
return output
}
Die Kamera läuft in einer eigenen Capture-Session mit eigener Uhr, ihre Zeitstempel werden also in Host-Zeit umgerechnet, bevor sie die gemeinsame Uhr erreichen:
var time = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
if let source = session.synchronizationClock {
time = CMSyncConvertTime(time, from: source, to: CMClockGetHostTimeClock())
}
Ein paar Details, die uns Zeit gekostet haben:
- Dateien starten spät. Die Kamera liefert ihren ersten Frame vielleicht eine halbe Sekunde nach dem Bildschirm. Jede Datei speichert ihre echte Startzeit und wird beim Export mit diesem Versatz platziert, nie bei null. Bei Video wird der erste Frame gehalten, bis die Datei wirklich beginnt, damit es kein schwarzes Aufblitzen gibt.
- Audioformate ändern sich mitten in der Aufnahme. Wechselst du während der Aufnahme das Mikrofon, kann sich das Sample-Format ändern. Statt dem alten Writer ein neues Format zu geben, schließen wir die Datei und öffnen zum aktuellen Zeitpunkt
mic-2.m4a. Der Exporter setzt die Teile wieder in die richtige Reihenfolge. - Schreib nur vollständige Frames. ScreenCaptureKit hängt an jeden Frame einen Status. Wir schreiben nur Frames mit
.completeund überspringen den Rest. - Schließ den letzten Frame sauber ab. Wenn sich auf dem Bildschirm nichts bewegt, kann der letzte Frame lange vor dem Stoppen ankommen. Wir hängen kurz vor der Endzeit eine Kopie davon an, damit das Video so lange dauert wie die Aufnahme.
Version drei: ein Compositor beim Export
Wenn du Stopp drückst, bauen wir aus den Projektdateien eine AVMutableComposition und rendern sie mit einem eigenen AVVideoCompositing-Compositor. Für jeden Ausgabe-Frame bekommt er den Bildschirm- und den Kamera-Frame und zeichnet das Ergebnis mit Core Image: Hintergrund und Rand, Klick-Pulse, einen neu gezeichneten Cursor, die virtuelle Kamera für den Auto-Zoom und zum Schluss die Kamerablase, maskiert als Kreis, Quadrat oder 16:9, mit Rahmen und Schatten.
Der Ton läuft über einen Reader/Writer-Exporter: Ein AVAssetReaderAudioMixOutput mischt Systemaudio und Mikrofon zu einem Stereo-Stream mit 48 kHz, der neben dem H.264-Video als AAC codiert wird. Weil Zoom und Kamerablase hier gezeichnet werden, heißt einen Zoom später ändern einfach: aus denselben Dateien neu rendern.
Dieselbe Komposition speist auch die Vorschau. Während der Export noch läuft, zeigt der Player die Komposition live, du kannst dir das Video also gleich nach dem Stoppen ansehen.
Angefangen haben wir mit HEVC-Export und sind dann auf H.264 umgestiegen. Die Dateien sind etwas größer, aber sie laufen in jedem Browser auf dem Computer jedes Kunden, und das zählt bei einem Link zum Teilen mehr als ein paar Megabyte.
Der Encoder ist der Flaschenhals
Wir dachten, das Zeichnen wäre der langsame Teil. Ist es nicht. Auf einem M1 schafft der H.264-Hardware-Encoder bei 2880×1800 höchstens etwa 85 Frames pro Sekunde. Eine Aufnahme von zehn Minuten mit 30 fps hat 18.000 Frames, selbst im besten Fall dauert der Export eines Retina-Videos also etwa dreieinhalb Minuten.
Der Beweis, dass es nicht am Zeichnen liegt: Als wir Auto-Zoom mit Bewegungsunschärfe und Frame-Überblendung bei Zoomwechseln hinzugefügt haben, hat sich die Exportgeschwindigkeit nicht verändert. Core Image auf der GPU hält locker mit, das Tempo gibt der Encoder vor.
Die Hebel liegen also beim Encoder und beim Warten, nicht im Zeichencode. Der größte für Nutzer ist der Schalter für die Auflösung unter .
| Hebel | Wirkung |
|---|---|
| Full HD statt Retina | Weniger Pixel pro Frame für den Encoder |
| Aufnahme mit 30 fps | Halb so viele Frames wie mit 60 fps |
| Komposition live in der Vorschau zeigen | Du musst nicht warten, um zuzuschauen |
| Hochladen erst nach dem Export | Eine Datei, ein Upload, kein Neucodieren auf dem Server |
Stolperfallen, die nichts mit der Aufnahme zu tun hatten
Die Berechtigung, die immer wiederkam
macOS fragte immer wieder nach der Berechtigung für die Bildschirmaufnahme, obwohl wir sie erteilt hatten. Die Ursache: Eine alte, ad hoc signierte Debug-Kopie mit derselben Bundle-ID lag noch auf der Festplatte und hielt die Freigabe. Das System sah zwei Apps mit einem Namen und unterschiedlichen Signaturen.
Die Lösung war, die zusätzlichen Kopien zu löschen und die Berechtigung zurückzusetzen:
tccutil reset ScreenCapture dev.wearenube.rec
Die Lehre betrifft das Signieren, nicht ScreenCaptureKit: Signier jeden Build auf dieselbe Weise und liefere nie Ad-hoc-Builds aus. Wenn du als Nutzer in derselben Schleife steckst, zeigt unser Leitfaden zur Bildschirmaufnahme-Berechtigung am Mac die Schritte, auch ohne Terminal.
Ein gesperrter Mac nimmt das Hintergrundbild auf
Wenn sich der Bildschirm während der Aufnahme sperrt, zeigt das Video nur das Hintergrundbild. Nichts stürzt ab, es gibt keinen Fehler: Du bekommst einfach minutenlang Schreibtischhintergrund. Halte den Mac wach und lass ihn während der Aufnahme nicht sperren. Das ist die Art Fehler, die man erst bemerkt, wenn der Kunde sie bemerkt.
Der VideoPlayer von SwiftUI stürzte ab
Unsere Player-Ansichten nutzten zuerst VideoPlayer von SwiftUI. Mit dem SDK von Xcode 26 gebaut, stürzte er unter macOS 15 ab. Wir haben stattdessen AVPlayerView aus AppKit eingebunden, und die Abstürze hörten auf:
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 }
}
}
Kein Platz mehr auf der Festplatte
Getrennte Dateien plus Export heißen: Eine Aufnahme belegt kurzzeitig mehr als das Doppelte ihrer endgültigen Größe. JustRec startet keine Aufnahme mit weniger als 2 GB freiem Speicher und stoppt und sichert, wenn der freie Platz während einer Aufnahme unter 500 MB fällt.
Was wir wieder so machen würden
Wenn du auf ScreenCaptureKit baust
- Wähl deine minimale macOS-Version nach den APIs, die du brauchst, nicht umgekehrt.
- Bring jede Quelle vom ersten Tag an auf eine Uhr, Sync nachzurüsten ist mühsam.
- Behalte die Rohquellen und render beim Export, wenn etwas bearbeitbar bleiben muss.
- Miss den Encoder, bevor du deinen Zeichencode optimierst.
- Signier konsequent, auch in der Entwicklung, und lösch alte Builds.
- Teste mit gesperrtem Bildschirm, voller Festplatte und einem Mikrofon, das mitten in der Aufnahme wechselt.
Wir würden auch wieder mit SCRecordingOutput anfangen. Es hat uns schnell zu einem funktionierenden Prototyp gebracht und uns genau gezeigt, welche Grenzen für unser Produkt zählen.
Die saubere Mikrofonspur hat ein zweites Leben: Aus ihr entstehen die Untertitel. Diese Geschichte steht in Whisper auf dem Mac: Untertitel ohne Cloud.
Was nach dem Upload passiert, liest du in Cloudflare R2: Was uns das Hosting von Kundenvideos kostet. Die Nutzerseite der Kamerablase zeigt unser Leitfaden, wie du Bildschirm und Kamera am Mac gleichzeitig aufnimmst, und die Funktionsübersicht zeigt, was all diese Technik am Ende ergibt.
Quellen
- Apple Developer: ScreenCaptureKit, geprüft im November 2026.
- Apple Developer: SCStreamConfiguration.captureMicrophone (macOS 15.0), geprüft im November 2026.
- Apple Developer: SCStreamConfiguration.microphoneCaptureDeviceID (macOS 15.0), geprüft im November 2026.
- Apple Developer: SCRecordingOutput (macOS 15.0), geprüft im November 2026.
- Apple Developer: SCFrameStatus, geprüft im November 2026.
- Apple Developer: AVVideoCompositing, geprüft im November 2026.
- Apple Developer: AVAssetReaderAudioMixOutput, geprüft im November 2026.
- Apple Developer: AVPlayerView, geprüft im November 2026.
Häufige Fragen
Was ist ScreenCaptureKit?
ScreenCaptureKit ist Apples Framework, um unter macOS Bildschirminhalte und Ton aufzunehmen. Du wählst mit einem Inhaltsfilter Bildschirme, Fenster oder Apps aus, und es liefert Video und Audio als Sample-Buffer, die deine App verarbeiten oder in eine Datei schreiben kann.
Welche macOS-Version braucht man für die Mikrofonaufnahme in ScreenCaptureKit?
macOS 15. Die Optionen captureMicrophone und microphoneCaptureDeviceID von SCStreamConfiguration, der Ausgabetyp für das Mikrofon und SCRecordingOutput kamen alle mit macOS 15.0. Deshalb setzt JustRec macOS 15 Sequoia oder neuer voraus.
Soll ich SCRecordingOutput oder AVAssetWriter nehmen?
SCRecordingOutput ist der schnellste Weg zu einer fertigen Datei. Wenn du getrennte Spuren brauchst, eine Kamera aus einer anderen Capture-Session, Pausen oder Effekte, die nach der Aufnahme bearbeitbar bleiben, gibt dir das selbst Schreiben der Sample-Buffer mit AVAssetWriter diese Kontrolle.
Warum zeigt eine Aufnahme mit ScreenCaptureKit nur das Hintergrundbild?
In unseren Tests passierte das, wenn der Mac während der Aufnahme gesperrt war: Das Video zeigte das Hintergrundbild statt der Apps. Halte den Mac wach und lass ihn nicht sperren, während du aufnimmst.
Warum fragt macOS während der Entwicklung immer wieder nach der Berechtigung für die Bildschirmaufnahme?
Die Berechtigung ist an Identität und Signatur der App gebunden. Wenn eine alte Kopie mit derselben Bundle-ID, aber anderer Signatur die Freigabe hält, fragt der neue Build immer wieder. Lösch zusätzliche Kopien, setz die Berechtigung mit tccutil zurück und signier jeden Build auf dieselbe Weise.



