IT

ScreenCaptureKit: cosa abbiamo imparato creando un registratore Mac

Come abbiamo creato un registratore schermo nativo per Mac con ScreenCaptureKit: writer separati su un solo clock, un compositor Core Image, i limiti dell'encoder.

Nicolas Ricardi9 min di lettura

In breve

  • Siamo partiti con SCRecordingOutput, poi l'abbiamo sostituito con i nostri AVAssetWriter: un file per sorgente, tutti sincronizzati su un unico clock condiviso.
  • La bolla della webcam, il cursore e lo zoom vengono disegnati in esportazione da un compositor AVVideoCompositing personalizzato, così restano modificabili dopo la registrazione.
  • Su un M1 l'encoder hardware H.264 arriva al massimo a circa 85 fps a 2880×1800: la velocità di esportazione la decide l'encoder, non il disegno.
  • Quasi tutti i bug più difficili non stavano nel codice di cattura: permessi legati alle firme, uno schermo bloccato, un crash del player SwiftUI.

ScreenCaptureKit è il framework di macOS per catturare lo schermo, e fa bene quell’unico lavoro: ti consegna fotogrammi e audio. File, sincronizzazione, webcam, esportazione e permessi li devi costruire tu. Ecco cosa abbiamo imparato sviluppandoci sopra JustRec, un registratore nativo che vive nella barra dei menu.

In breve: siamo partiti con l’output di registrazione integrato di Apple, siamo passati a writer nostri che salvano ogni sorgente in un file separato su un unico clock condiviso, e ora disegniamo tutto il resto in esportazione. I bug più difficili non avevano niente a che fare con la cattura.

  1. Prima versione

    SCRecordingOutput

    Un flusso, un MP4 finito. La strada più rapida per un prototipo funzionante.

  2. Cosa non andava

    Un file solo non bastava

    La webcam, uno zoom modificabile e una traccia vocale pulita per i sottotitoli non stavano in un unico file registrato.

  3. Seconda versione

    Writer separati su un unico clock

    Schermo, webcam, audio di sistema e microfono vanno ognuno nel proprio AVAssetWriter, sincronizzati da un unico clock di cattura condiviso.

  4. Terza versione

    Un compositor in esportazione

    Bolla, cursore, clic e zoom vengono disegnati con Core Image quando premi stop, così restano modificabili.

Perché richiediamo macOS 15

JustRec funziona su macOS 15 Sequoia o successivo. Non è stata una scelta di marketing. Con macOS 15, ScreenCaptureKit ha guadagnato la cattura del microfono: captureMicrophone e microphoneCaptureDeviceID su SCStreamConfiguration, più un tipo di output .microphone sul flusso. SCRecordingOutput è arrivato nella stessa versione.

La cattura del microfono dentro il flusso significa che audio di sistema e voce arrivano dalla stessa sorgente, con timestamp dello stesso clock. Sui sistemi più vecchi dovresti avviare una cattura audio separata e allinearla tu. Rinunciare a macOS 14 ci costa qualche potenziale utente e ci risparmia un’intera categoria di bug di sincronizzazione.

let config = SCStreamConfiguration()
config.minimumFrameInterval = CMTime(value: 1, timescale: 30)
config.capturesAudio = true                    // audio di sistema
config.excludesCurrentProcessAudio = true      // non i nostri suoni
config.captureMicrophone = true                // macOS 15
config.microphoneCaptureDeviceID = microphoneID // macOS 15

Prima versione: SCRecordingOutput

Il nostro primo prototipo usava SCRecordingOutput. Lo colleghi al flusso, gli dai l’URL di un file, e quando interrompi ottieni un MP4. Per una semplice registrazione dello schermo è la strada più rapida verso un’app funzionante, e te lo consigliamo se ti serve solo quello.

Ha smesso di andarci bene quando abbiamo capito cosa doveva fare il prodotto:

  • La webcam arriva da un’altra parte. La bolla della webcam è una AVCaptureSession (fotocamera integrata, webcam esterna o un iPhone tramite Fotocamera Continuity), non fa parte del flusso dello schermo.
  • Volevamo la bolla fuori dalla cattura dello schermo. Se la finestra della bolla è impressa nei fotogrammi dello schermo, lo zoom automatico ingrandisce anche lei. Noi volevamo zoomare sullo schermo e lasciare la bolla della stessa dimensione.
  • I sottotitoli hanno bisogno di una traccia vocale pulita. La trascrizione funziona meglio sul solo microfono, senza i suoni di sistema mescolati, e può partire prima che esista il video finale.
  • Gli effetti devono restare modificabili. Zoom automatico, clic evidenziati e cursore si possono cambiare dopo la registrazione, quindi non si possono imprimere mentre registri.

Un unico file finito non poteva darci niente di tutto questo, così abbiamo scritto i nostri writer.

Cosa è cambiato quando abbiamo abbandonato SCRecordingOutput
SCRecordingOutput (prima versione) I nostri writer (oggi)
Output Un MP4 finito Un file per sorgente, più events.json
Webcam da una AVCaptureSession ✗ non fa parte del flusso ✓ un file suo, sullo stesso clock
Zoom senza ingrandire la bolla ✗ bolla impressa nei fotogrammi ✓ bolla disegnata dopo lo zoom
Traccia vocale per i sottotitoli ✗ aspetta il file finito ✓ microfono su una traccia sua
Effetti modificabili dopo la registrazione ✗ ✓ renderizzati di nuovo dagli stessi file
Codice da scrivere e mantenere Pochissimo Writer, clock, compositor, exporter

Seconda versione: writer separati su un unico clock

Ora ogni sorgente va nel proprio AVAssetWriter: screen.mp4, camera.mp4, una traccia di audio di sistema e una traccia del microfono, più un events.json con la posizione del mouse (campionata 60 volte al secondo), i clic, la posizione e la forma della bolla, e le immagini del cursore. Tutto sta in una cartella di progetto; l’MP4 che condividi viene renderizzato da lì.

La regola che ha fatto funzionare tutto: ogni campione prende il suo tempo da un unico clock di cattura condiviso, basato sull’host time clock. Il clock gestisce anche la pausa. I fotogrammi che arrivano durante una pausa vengono scartati, e la durata di ogni pausa passata viene sottratta dai timestamp successivi, così i file non hanno buchi.

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 } // in pausa: scarta
        output = output - (end - pause.start)
    }
    return output
}

La webcam gira su una sua sessione di cattura con un suo clock, quindi i suoi timestamp vengono convertiti in host time prima di arrivare al clock condiviso:

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

Alcuni dettagli che ci sono costati tempo:

  • I file partono in ritardo. La webcam può consegnare il primo fotogramma mezzo secondo dopo lo schermo. Ogni file registra il suo vero orario di inizio, e in esportazione viene posizionato a quello scarto, mai a zero. Per il video, il primo fotogramma viene tenuto fermo finché il file non inizia davvero, così non c’è un lampo nero.
  • I formati audio cambiano durante la registrazione. Se cambi microfono mentre registri, il formato dei campioni può cambiare. Invece di dare al vecchio writer un formato nuovo, chiudiamo il file e apriamo mic-2.m4a dal momento attuale. L’exporter rimette i pezzi in ordine.
  • Scrivi solo fotogrammi completi. ScreenCaptureKit associa uno stato a ogni fotogramma. Noi scriviamo solo quelli marcati .complete e saltiamo gli altri.
  • Chiudi bene l’ultimo fotogramma. Se sullo schermo non si muove niente, l’ultimo fotogramma può arrivare molto prima che premi stop. Ne aggiungiamo una copia appena prima dell’orario di fine, così il video dura quanto la registrazione.

Terza versione: un compositor in esportazione

Quando premi stop, costruiamo una AVMutableComposition dai file del progetto e la renderizziamo con un compositor AVVideoCompositing personalizzato. Per ogni fotogramma in uscita riceve il fotogramma dello schermo e quello della webcam, e disegna il risultato con Core Image: lo sfondo e i margini, gli impulsi dei clic, un cursore ridisegnato, la fotocamera virtuale per lo zoom automatico e infine la bolla della webcam, mascherata in un cerchio, un quadrato o un 16:9, con bordo e ombra.

L’audio passa da un exporter reader/writer: un AVAssetReaderAudioMixOutput mixa l’audio di sistema e il microfono in un unico flusso stereo a 48 kHz, che viene codificato in AAC accanto al video H.264. Dato che zoom e bolla vengono disegnati qui, cambiare uno zoom in seguito significa solo renderizzare di nuovo dagli stessi file.

La stessa composizione alimenta anche l’anteprima. Mentre l’esportazione è ancora in corso, il player mostra la composizione dal vivo, così puoi guardare il video subito dopo aver premuto stop.

Siamo partiti esportando in HEVC e siamo passati ad H.264. I file sono un po’ più pesanti, ma si riproducono in qualsiasi browser sul computer di qualsiasi cliente, e per un link da condividere conta più di qualche megabyte.

Il collo di bottiglia è l’encoder

Pensavamo che la parte lenta fosse il disegno. Non lo è. Su un M1, l’encoder hardware H.264 arriva al massimo a circa 85 fotogrammi al secondo a 2880×1800. Una registrazione di dieci minuti a 30 fps sono 18.000 fotogrammi, quindi anche nel caso migliore l’esportazione di un video Retina richiede circa tre minuti e mezzo.

La prova che il problema non è il disegno: quando abbiamo aggiunto lo zoom automatico con motion blur e la fusione dei fotogrammi durante i cambi di zoom, la velocità di esportazione non è cambiata. Core Image sulla GPU tiene il passo senza fatica; il ritmo lo detta l’encoder.

Quindi le leve riguardano l’encoder e l’attesa, non il codice di disegno. Per gli utenti la più importante è l’opzione della risoluzione in SettingsVideo.

Cosa accorcia davvero l'attesa
Leva Effetto
Full HD invece di Retina Meno pixel per fotogramma per l’encoder
Cattura a 30 fps La metà dei fotogrammi rispetto a 60 fps
Mostrare la composizione dal vivo nell’anteprima Non devi aspettare per guardare
Caricare solo dopo l’esportazione Un file, un caricamento, nessuna ricodifica sul server

Trappole che non riguardavano la cattura

Il permesso di registrazione dello schermo che tornava sempre

macOS continuava a chiedere il permesso di registrazione dello schermo anche dopo che l’avevamo concesso. La causa: una vecchia copia di debug con firma ad-hoc e lo stesso bundle id era ancora sul disco e teneva l’autorizzazione. Il sistema vedeva due app con un solo nome e firme diverse.

La soluzione è stata eliminare le copie in più e reimpostare il permesso:

tccutil reset ScreenCapture dev.wearenube.rec

La lezione riguarda la firma, non ScreenCaptureKit: firma ogni build allo stesso modo e non distribuire mai build ad-hoc. Se sei un utente bloccato nello stesso circolo, la nostra guida al permesso di registrazione schermo su Mac che non funziona spiega i passaggi senza bisogno del Terminale.

Un Mac bloccato registra lo sfondo

Se lo schermo si blocca mentre registri, il video mostra solo lo sfondo della scrivania. Niente va in crash e non compare nessun errore: ti ritrovi solo con minuti di sfondo. Tieni il Mac sveglio e non lasciare che si blocchi mentre registri. È il tipo di errore che noti solo quando lo nota il cliente.

VideoPlayer di SwiftUI andava in crash

Le nostre viste del player all’inizio usavano VideoPlayer di SwiftUI. Compilato con l’SDK di Xcode 26, andava in crash su macOS 15. Abbiamo incapsulato invece AVPlayerView di AppKit e i crash si sono fermati:

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

Lo spazio su disco che finisce

File separati più un’esportazione significano che per un momento una registrazione occupa più del doppio della sua dimensione finale. JustRec non avvia la registrazione con meno di 2 GB liberi, e si ferma e salva se durante una registrazione lo spazio libero scende sotto i 500 MB.

Cosa rifaremmo

Se stai sviluppando su ScreenCaptureKit

  • Scegli la versione minima di macOS in base alle API che ti servono, non il contrario.
  • Metti ogni sorgente su un unico clock dal primo giorno; aggiungere la sincronizzazione dopo è doloroso.
  • Conserva le sorgenti grezze e renderizza in esportazione se qualcosa deve restare modificabile.
  • Misura l’encoder prima di ottimizzare il codice di disegno.
  • Firma in modo coerente, anche in sviluppo, ed elimina le vecchie build.
  • Fai test con lo schermo bloccato, il disco pieno e un microfono che cambia durante la registrazione.

Ripartiremmo anche da SCRecordingOutput. Ci ha dato in fretta un prototipo funzionante e ci ha mostrato esattamente quali limiti contavano per il nostro prodotto.

La traccia pulita del microfono ha una seconda vita: è da lì che nascono i sottotitoli. La storia è in come facciamo girare Whisper sul Mac.

Per quello che succede dopo il caricamento, leggi quanto costa ospitare i video dei clienti su Cloudflare R2. Per il lato della bolla della webcam che vede l’utente, la nostra guida per registrare schermo e webcam contemporaneamente la mostra in pratica, e l’elenco delle funzioni mostra a cosa serve tutta questa macchina.

Fonti

Domande frequenti

Che cos'è ScreenCaptureKit?

ScreenCaptureKit è il framework di Apple per catturare i contenuti dello schermo e l'audio su macOS. Scegli schermi, finestre o app con un filtro sui contenuti, e lui ti consegna video e audio come sample buffer che la tua app può elaborare o scrivere su file.

Quale versione di macOS serve per catturare il microfono con ScreenCaptureKit?

macOS 15. Le opzioni captureMicrophone e microphoneCaptureDeviceID di SCStreamConfiguration, il tipo di output microfono e SCRecordingOutput sono arrivati tutti con macOS 15.0, ed è per questo che JustRec richiede macOS 15 Sequoia o successivo.

Meglio SCRecordingOutput o AVAssetWriter?

SCRecordingOutput è il modo più rapido per ottenere un unico file finito. Se ti servono tracce separate, una webcam da un'altra sessione di cattura, la gestione della pausa o effetti modificabili dopo la registrazione, scrivere tu i sample buffer con AVAssetWriter ti dà quel controllo.

Perché una registrazione con ScreenCaptureKit mostra solo lo sfondo?

Nei nostri test succedeva quando il Mac era bloccato durante la registrazione: il video mostrava lo sfondo della scrivania invece delle app. Mentre registri, tieni il Mac sveglio e non lasciare che si blocchi.

Perché macOS continua a chiedere il permesso di registrazione dello schermo durante lo sviluppo?

Il permesso è legato all'identità e alla firma dell'app. Se una vecchia copia con lo stesso bundle id ma una firma diversa tiene l'autorizzazione, la nuova build continua a chiederla. Elimina le copie in più, reimposta il permesso con tccutil e firma ogni build allo stesso modo.

Scritto da

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
Dietro le quinte