FR

ScreenCaptureKit : nos leçons en créant un enregistreur d’écran Mac

Comment nous avons construit un enregistreur d’écran Mac natif sur ScreenCaptureKit : writers séparés sur une horloge, compositeur Core Image, limites de l’encodeur.

Nicolas Ricardi9 min de lecture

En bref

  • Nous avons commencé avec SCRecordingOutput, puis l’avons remplacé par nos propres AVAssetWriter : un fichier par source, tous calés sur une horloge commune.
  • La bulle caméra, le curseur et le zoom sont dessinés à l’export par un compositeur AVVideoCompositing maison : ils restent modifiables après l’enregistrement.
  • Sur un M1, l’encodeur matériel H.264 plafonne autour de 85 images par seconde en 2880×1800 : c’est l’encodeur qui fixe la vitesse d’export, pas le dessin.
  • La plupart de nos bugs les plus coriaces n’étaient pas dans le code de capture : autorisations liées aux signatures, écran verrouillé, plantage d’un lecteur SwiftUI.

ScreenCaptureKit est le framework de macOS pour capturer l’écran, et il fait bien ce seul travail : il vous livre des images et de l’audio. Les fichiers, la synchronisation, la caméra, l’export et les autorisations, c’est à vous de les construire. Voici ce que nous avons appris en développant dessus JustRec, un enregistreur natif qui vit dans la barre des menus.

En bref : nous sommes partis de la sortie d’enregistrement intégrée d’Apple, puis sommes passés à nos propres writers, qui sauvegardent chaque source dans son propre fichier sur une horloge commune, et nous dessinons désormais tout le reste à l’export. Les bugs les plus difficiles n’avaient rien à voir avec la capture.

  1. Version un

    SCRecordingOutput

    Un flux, un MP4 terminé. Le chemin le plus rapide vers un prototype qui marche.

  2. Ce qui a coincé

    Un seul fichier ne suffisait pas

    La caméra, un zoom modifiable et une piste voix propre pour les sous-titres ne tenaient pas dans un seul fichier enregistré.

  3. Version deux

    Des writers séparés sur une horloge

    Écran, caméra, audio système et micro vont chacun dans leur propre AVAssetWriter, calés sur une horloge de capture commune.

  4. Version trois

    Un compositeur à l’export

    La bulle, le curseur, les clics et le zoom sont dessinés avec Core Image quand vous appuyez sur stop : ils restent modifiables.

Pourquoi nous exigeons macOS 15

JustRec fonctionne sous macOS 15 Sequoia ou version ultérieure. Ce n’était pas un choix marketing. Avec macOS 15, ScreenCaptureKit a gagné la capture du micro : captureMicrophone et microphoneCaptureDeviceID sur SCStreamConfiguration, plus un type de sortie .microphone sur le flux. SCRecordingOutput est arrivé dans la même version.

Capturer le micro dans le flux signifie que l’audio système et votre voix viennent de la même source, avec des horodatages issus de la même horloge. Sur les systèmes plus anciens, il faudrait lancer une capture audio séparée et l’aligner soi-même. Abandonner macOS 14 nous coûte quelques utilisateurs potentiels et nous épargne toute une famille de bugs de synchronisation.

let config = SCStreamConfiguration()
config.minimumFrameInterval = CMTime(value: 1, timescale: 30)
config.capturesAudio = true                    // audio système
config.excludesCurrentProcessAudio = true      // pas nos propres sons
config.captureMicrophone = true                // macOS 15
config.microphoneCaptureDeviceID = microphoneID // macOS 15

Version un : SCRecordingOutput

Notre premier prototype utilisait SCRecordingOutput. Vous l’attachez au flux, vous lui donnez l’URL d’un fichier, et vous obtenez un MP4 à l’arrêt. Pour un simple enregistrement d’écran, c’est le chemin le plus rapide vers une app qui fonctionne, et nous le recommandons si c’est tout ce dont vous avez besoin.

Il a cessé de convenir dès que nous avons su ce que le produit devait faire :

  • La caméra vient d’ailleurs. La bulle caméra est une AVCaptureSession (caméra intégrée, webcam externe ou iPhone grâce à la caméra Continuité), elle ne fait pas partie du flux de l’écran.
  • Nous voulions la bulle hors de la capture d’écran. Si la fenêtre de la bulle est incrustée dans les images de l’écran, un zoom automatique l’agrandit aussi. Nous voulions zoomer sur l’écran et garder la bulle à la même taille.
  • Les sous-titres demandent une piste voix propre. La transcription fonctionne mieux sur le micro seul, sans les sons du système mélangés, et elle peut commencer avant que la vidéo finale existe.
  • Les effets doivent rester modifiables. Le zoom automatique, la mise en évidence des clics et le curseur peuvent être modifiés après l’enregistrement : ils ne peuvent donc pas être figés pendant que vous enregistrez.

Un seul fichier terminé ne pouvait rien nous offrir de tout cela : nous avons donc écrit nos propres writers.

Ce qui a changé quand nous avons abandonné SCRecordingOutput
SCRecordingOutput (version un) Nos propres writers (aujourd’hui)
Sortie Un MP4 terminé Un fichier par source, plus events.json
Caméra issue d’une AVCaptureSession ✗ hors du flux ✓ son propre fichier, sur la même horloge
Zoom sans agrandir la bulle ✗ bulle incrustée dans les images ✓ bulle dessinée après le zoom
Piste voix pour les sous-titres ✗ attend le fichier terminé ✓ micro sur sa propre piste
Effets modifiables après l’enregistrement ✗ ✓ rendus à nouveau à partir des mêmes fichiers
Code à écrire et à maintenir Très peu Writers, horloge, compositeur, exporteur

Version deux : des writers séparés sur une horloge

Chaque source va désormais dans son propre AVAssetWriter : screen.mp4, camera.mp4, une piste audio système et une piste micro, plus un events.json avec la position de la souris (échantillonnée 60 fois par seconde), les clics, la position et la forme de la bulle, et les images du curseur. Le tout vit dans un dossier de projet ; le MP4 que vous partagez est rendu à partir de lui.

La règle qui a tout fait fonctionner : chaque échantillon reçoit son temps d’une seule horloge de capture commune, basée sur l’horloge de l’hôte. L’horloge gère aussi la pause. Les images qui arrivent pendant une pause sont jetées, et la durée de chaque pause passée est soustraite des horodatages suivants : les fichiers n’ont donc pas de trous.

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 } // en pause : on jette
        output = output - (end - pause.start)
    }
    return output
}

La caméra tourne sur sa propre session de capture, avec sa propre horloge : ses horodatages sont donc convertis en temps de l’hôte avant d’arriver à l’horloge commune.

var time = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
if let source = session.synchronizationClock {
    time = CMSyncConvertTime(time, from: source, to: CMClockGetHostTimeClock())
}

Quelques détails qui nous ont coûté du temps :

  • Les fichiers démarrent en retard. La caméra peut livrer sa première image une demi-seconde après l’écran. Chaque fichier enregistre son heure de début réelle, et à l’export il est placé à ce décalage, jamais à zéro. Pour la vidéo, la première image est maintenue jusqu’au vrai début du fichier : pas de flash noir.
  • Les formats audio changent en cours d’enregistrement. Changez de micro pendant l’enregistrement et le format des échantillons peut changer. Au lieu de donner un nouveau format à l’ancien writer, nous fermons le fichier et ouvrons mic-2.m4a au temps courant. L’exporteur remet les morceaux dans l’ordre.
  • N’écrire que des images complètes. ScreenCaptureKit associe un statut à chaque image. Nous n’écrivons que les images marquées .complete et ignorons les autres.
  • Bien clore la dernière image. Si rien ne bouge à l’écran, la dernière image peut arriver bien avant que vous appuyiez sur stop. Nous en ajoutons une copie juste avant l’heure de fin, pour que la vidéo dure aussi longtemps que l’enregistrement.

Version trois : un compositeur à l’export

Quand vous appuyez sur stop, nous construisons une AVMutableComposition à partir des fichiers du projet et la rendons avec un compositeur AVVideoCompositing maison. Pour chaque image de sortie, il reçoit l’image de l’écran et celle de la caméra, et dessine le résultat avec Core Image : l’arrière-plan et les marges, les pulsations des clics, un curseur redessiné, la caméra virtuelle du zoom automatique, et enfin la bulle caméra, masquée en cercle, en carré ou en 16:9, avec une bordure et une ombre.

L’audio passe par un exporteur lecteur/writer : un AVAssetReaderAudioMixOutput mixe l’audio système et le micro en un seul flux stéréo à 48 kHz, encodé en AAC à côté de la vidéo H.264. Comme le zoom et la bulle sont dessinés ici, modifier un zoom plus tard revient simplement à refaire le rendu à partir des mêmes fichiers.

La même composition alimente aussi l’aperçu. Pendant que l’export tourne encore, le lecteur affiche la composition en direct : vous pouvez regarder la vidéo juste après l’arrêt.

Nous avons commencé par exporter en HEVC, puis sommes passés au H.264. Les fichiers sont un peu plus lourds, mais ils se lisent dans n’importe quel navigateur, sur l’ordinateur de n’importe quel client, ce qui compte plus pour un lien de partage que quelques mégaoctets.

L’encodeur est le goulot d’étranglement

Nous pensions que le dessin serait la partie lente. Ce n’est pas le cas. Sur un M1, l’encodeur matériel H.264 plafonne autour de 85 images par seconde en 2880×1800. Un enregistrement de dix minutes à 30 i/s, c’est 18 000 images : même dans le meilleur des cas, l’export d’une vidéo Retina prend environ trois minutes et demie.

La preuve que le dessin n’est pas le problème : quand nous avons ajouté le zoom automatique avec flou de mouvement et fondu des images pendant les changements de zoom, la vitesse d’export n’a pas bougé. Core Image sur le GPU suit sans peine ; c’est l’encodeur qui donne le rythme.

Les leviers concernent donc l’encodeur et l’attente, pas le code de dessin. Le plus important pour les utilisateurs est le choix de résolution dans SettingsVideo.

Ce qui raccourcit vraiment l’attente
Levier Effet
Full HD au lieu de Retina Moins de pixels par image pour l’encodeur
Capture à 30 i/s Moitié moins d’images qu’à 60 i/s
Afficher la composition en direct dans l’aperçu Pas besoin d’attendre pour regarder
Mettre en ligne seulement après l’export Un fichier, un envoi, aucun réencodage sur le serveur

Les pièges qui n’avaient rien à voir avec la capture

Une autorisation d’enregistrement d’écran qui revenait sans cesse

macOS redemandait sans cesse l’autorisation d’enregistrer l’écran, même après que nous l’avions accordée. La cause : une ancienne copie de debug signée ad hoc, avec le même bundle id, était encore sur le disque et détenait l’autorisation. Le système voyait deux apps avec un même nom et des signatures différentes.

La solution a été de supprimer les copies en trop et de réinitialiser l’autorisation :

tccutil reset ScreenCapture dev.wearenube.rec

La leçon porte sur la signature, pas sur ScreenCaptureKit : signez chaque build de la même façon et ne distribuez jamais de builds ad hoc. Si vous êtes un utilisateur coincé dans la même boucle, notre guide sur l’autorisation d’enregistrement d’écran bloquée sur Mac détaille les étapes sans le terminal.

Un Mac verrouillé enregistre le fond d’écran

Si l’écran se verrouille pendant l’enregistrement, la vidéo ne montre que le fond d’écran. Rien ne plante et aucune erreur n’apparaît : vous obtenez juste des minutes de fond de bureau. Gardez le Mac éveillé et ne le laissez pas se verrouiller pendant l’enregistrement. C’est le genre d’échec qu’on ne remarque que quand le client le remarque.

Le VideoPlayer de SwiftUI plantait

Nos vues de lecteur utilisaient d’abord le VideoPlayer de SwiftUI. Compilé avec le SDK de Xcode 26, il plantait sous macOS 15. Nous avons enveloppé l’AVPlayerView d’AppKit à la place, et les plantages ont cessé :

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 }
    }
}

Manquer d’espace disque

Des fichiers séparés plus un export, cela veut dire qu’un enregistrement occupe brièvement plus du double de sa taille finale. JustRec refuse de lancer un enregistrement avec moins de 2 Go libres, et s’arrête en sauvegardant si l’espace libre passe sous 500 Mo pendant un enregistrement.

Ce que nous referions

Si vous développez sur ScreenCaptureKit

  • Choisissez votre version minimale de macOS selon les API dont vous avez besoin, pas l’inverse.
  • Mettez toutes les sources sur une même horloge dès le premier jour ; ajouter la synchronisation après coup est pénible.
  • Gardez les sources brutes et faites le rendu à l’export si quelque chose doit rester modifiable.
  • Mesurez l’encodeur avant d’optimiser votre code de dessin.
  • Signez de façon cohérente, même en développement, et supprimez les anciens builds.
  • Testez avec l’écran verrouillé, un disque plein et un micro qui change en cours d’enregistrement.

Nous recommencerions aussi avec SCRecordingOutput. Il nous a donné rapidement un prototype qui fonctionnait et nous a montré exactement quelles limites comptaient pour notre produit.

La piste micro propre a une seconde vie : c’est à partir d’elle que sont faits les sous-titres. Cette histoire est racontée dans comment nous faisons tourner Whisper sur le Mac.

Pour ce qui se passe après la mise en ligne, voyez combien coûte l’hébergement des vidéos clients sur Cloudflare R2. Pour le côté utilisateur de la bulle caméra, notre guide pour enregistrer votre écran et votre webcam en même temps la montre en pratique, et la liste des fonctionnalités montre ce que toute cette mécanique produit au final.

Sources

Questions fréquentes

Qu’est-ce que ScreenCaptureKit ?

ScreenCaptureKit est le framework d’Apple pour capturer le contenu de l’écran et l’audio sous macOS. Vous choisissez des écrans, des fenêtres ou des apps avec un filtre de contenu, et il fournit la vidéo et l’audio sous forme de sample buffers que votre app peut traiter ou écrire dans un fichier.

Quelle version de macOS faut-il pour capturer le micro avec ScreenCaptureKit ?

macOS 15. Les options captureMicrophone et microphoneCaptureDeviceID de SCStreamConfiguration, le type de sortie micro et SCRecordingOutput sont tous arrivés avec macOS 15.0 : c’est pourquoi JustRec demande macOS 15 Sequoia ou version ultérieure.

Faut-il utiliser SCRecordingOutput ou AVAssetWriter ?

SCRecordingOutput est le moyen le plus rapide d’obtenir un fichier terminé. Si vous avez besoin de pistes séparées, d’une caméra issue d’une autre session de capture, d’une gestion de la pause ou d’effets modifiables après l’enregistrement, écrire vous-même les sample buffers avec AVAssetWriter vous donne ce contrôle.

Pourquoi un enregistrement ScreenCaptureKit ne montre que le fond d’écran ?

Lors de nos tests, cela arrivait quand le Mac se verrouillait pendant l’enregistrement : la vidéo montrait le fond d’écran au lieu des apps. Gardez le Mac éveillé, et ne le laissez pas se verrouiller, pendant que vous enregistrez.

Pourquoi macOS redemande sans cesse l’autorisation d’enregistrer l’écran pendant le développement ?

L’autorisation est liée à l’identité et à la signature de l’app. Si une ancienne copie avec le même bundle id mais une signature différente détient l’autorisation, le nouveau build redemande sans arrêt. Supprimez les copies en trop, réinitialisez l’autorisation avec tccutil et signez chaque build de la même façon.

Écrit par

Nicolas Ricardi

Maker of JustRec, at Nube

Nicolas makes JustRec at Nube, a web studio in Italy. He built it for the videos Nube sends to its own clients: design reviews, handovers and quotes explained in a few minutes instead of a meeting.

www.wearenube.dev
En coulisses