Em resumo
- Começámos com o SCRecordingOutput e depois substituímo-lo pelos nossos próprios AVAssetWriter: um ficheiro por fonte, todos sincronizados num só relógio partilhado.
- A bolha da câmara, o cursor e o zoom são desenhados na exportação por um compositor AVVideoCompositing próprio, por isso continuam editáveis depois de gravar.
- Num M1, o encoder de hardware H.264 chega a cerca de 85 fps em 2880×1800: a velocidade de exportação é ditada pelo encoder, não pelo desenho.
- A maioria dos nossos bugs mais difíceis não estava no código de captura: permissões ligadas a assinaturas, um ecrã bloqueado, um crash no leitor SwiftUI.
O ScreenCaptureKit é a framework do macOS para captar o ecrã, e faz bem esse único trabalho: entrega-lhe fotogramas e áudio. Os ficheiros, a sincronização, a câmara, a exportação e as permissões ficam por sua conta. Eis o que aprendemos a construir sobre ele o JustRec, um gravador nativo da barra de menus.
Em resumo: começámos com a saída de gravação incorporada da Apple, passámos para writers nossos que guardam cada fonte no seu próprio ficheiro com um só relógio partilhado e agora desenhamos tudo o resto na exportação. Os bugs mais difíceis não tinham nada a ver com a captura.
Versão um
SCRecordingOutput
Um stream, um MP4 acabado. O caminho mais rápido para um protótipo a funcionar.
O que falhou
Um ficheiro não chegava
A câmara, um zoom editável e uma faixa de voz limpa para as legendas não cabiam num só ficheiro gravado.
Versão dois
Writers separados num só relógio
Ecrã, câmara, áudio do sistema e microfone vão cada um para o seu AVAssetWriter, sincronizados por um relógio de captura partilhado.
Versão três
Um compositor na exportação
A bolha, o cursor, os cliques e o zoom são desenhados com Core Image quando carrega em parar, por isso continuam editáveis.
Porque exigimos o macOS 15
O JustRec funciona no macOS 15 Sequoia ou posterior. Não foi uma escolha de marketing. No macOS 15, o ScreenCaptureKit passou a captar o microfone: captureMicrophone e microphoneCaptureDeviceID no SCStreamConfiguration, mais um tipo de saída .microphone no stream. O SCRecordingOutput chegou na mesma versão.
Captar o microfone dentro do stream significa que o áudio do sistema e a sua voz vêm da mesma fonte, com timestamps do mesmo relógio. Em sistemas mais antigos, teria de correr uma captura de áudio separada e alinhá-la por conta própria. Deixar cair o macOS 14 custa-nos alguns potenciais utilizadores e poupa-nos uma categoria inteira de bugs de sincronização.
let config = SCStreamConfiguration()
config.minimumFrameInterval = CMTime(value: 1, timescale: 30)
config.capturesAudio = true // áudio do sistema
config.excludesCurrentProcessAudio = true // sem os nossos próprios sons
config.captureMicrophone = true // macOS 15
config.microphoneCaptureDeviceID = microphoneID // macOS 15
Versão um: SCRecordingOutput
O nosso primeiro protótipo usava o SCRecordingOutput. Liga-o ao stream, dá-lhe o URL de um ficheiro e recebe um MP4 quando para. Para uma gravação de ecrã simples, é o caminho mais rápido para uma app a funcionar, e recomendamo-lo se for só disso que precisa.
Deixou de servir quando percebemos o que o produto tinha de fazer:
- A câmara vem de outro lado. A bolha da câmara é uma
AVCaptureSession(câmara integrada, webcam externa ou um iPhone através da Câmara de Continuidade), não faz parte do stream do ecrã. - Queríamos a bolha fora da captura do ecrã. Se a janela da bolha ficar gravada nos fotogramas do ecrã, um zoom automático amplia-a também. Queríamos fazer zoom no ecrã e manter a bolha do mesmo tamanho.
- As legendas precisam de uma faixa de voz limpa. A transcrição funciona melhor só com o microfone, sem os sons do sistema misturados, e pode começar antes de o vídeo final existir.
- Os efeitos têm de continuar editáveis. O zoom automático, o destaque dos cliques e o cursor podem ser alterados depois de gravar, por isso não podem ficar fixados durante a gravação.
Um único ficheiro acabado não nos dava nada disto, por isso escrevemos os nossos próprios writers.
| SCRecordingOutput (versão um) | Os nossos writers (agora) | |
|---|---|---|
| Resultado | Um MP4 acabado | Um ficheiro por fonte, mais events.json |
Câmara a partir de uma AVCaptureSession |
✗ não faz parte do stream | ✓ ficheiro próprio, no mesmo relógio |
| Zoom sem ampliar a bolha | ✗ bolha gravada nos fotogramas | ✓ bolha desenhada depois do zoom |
| Faixa de voz para as legendas | ✗ espera pelo ficheiro acabado | ✓ microfone na sua própria faixa |
| Efeitos editáveis depois de gravar | ✗ | ✓ renderizados de novo a partir dos mesmos ficheiros |
| Código para escrever e manter | Muito pouco | Writers, relógio, compositor, exportador |
Versão dois: writers separados num só relógio
Cada fonte vai agora para o seu próprio AVAssetWriter: screen.mp4, camera.mp4, uma faixa de áudio do sistema e uma faixa de microfone, mais um events.json com a posição do rato (amostrada 60 vezes por segundo), os cliques, a posição e a forma da bolha e as imagens do cursor. Tudo vive numa pasta de projeto; o MP4 que partilha é renderizado a partir dela.
A regra que fez isto funcionar: cada amostra recebe o tempo de um só relógio de captura partilhado, baseado no relógio do sistema (host time). O relógio também trata das pausas. Os fotogramas que chegam durante uma pausa são descartados, e a duração de cada pausa anterior é subtraída aos timestamps seguintes, por isso os ficheiros não têm buracos.
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 } // em pausa: descartar
output = output - (end - pause.start)
}
return output
}
A câmara corre na sua própria sessão de captura, com o seu próprio relógio, por isso os timestamps são convertidos para host time antes de chegarem ao relógio partilhado:
var time = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
if let source = session.synchronizationClock {
time = CMSyncConvertTime(time, from: source, to: CMClockGetHostTimeClock())
}
Alguns pormenores que nos custaram tempo:
- Os ficheiros começam tarde. A câmara pode entregar o primeiro fotograma meio segundo depois do ecrã. Cada ficheiro regista a sua hora real de início e, na exportação, é colocado nesse desfasamento, nunca no zero. No vídeo, o primeiro fotograma fica parado até o ficheiro começar de facto, por isso não há um flash preto.
- Os formatos de áudio mudam a meio da gravação. Se mudar de microfone durante a gravação, o formato das amostras pode mudar. Em vez de dar um formato novo ao writer antigo, fechamos o ficheiro e abrimos
mic-2.m4ano instante atual. O exportador volta a pôr as partes por ordem. - Só escrever fotogramas completos. O ScreenCaptureKit associa um estado a cada fotograma. Só escrevemos os fotogramas marcados como
.completee saltamos os restantes. - Fechar bem o último fotograma. Se nada se mexer no ecrã, o último fotograma pode chegar muito antes de carregar em parar. Acrescentamos uma cópia dele mesmo antes da hora de fim, para o vídeo durar tanto como a gravação.
Versão três: um compositor na exportação
Quando carrega em parar, construímos uma AVMutableComposition a partir dos ficheiros do projeto e renderizamo-la com um compositor AVVideoCompositing próprio. Para cada fotograma de saída, recebe o fotograma do ecrã e o da câmara e desenha o resultado com Core Image: o fundo e as margens, os pulsos dos cliques, um cursor redesenhado, a câmara virtual do zoom automático e, por fim, a bolha da câmara, recortada em círculo, quadrado ou 16:9, com contorno e sombra.
O áudio passa por um exportador de leitura e escrita: um AVAssetReaderAudioMixOutput mistura o áudio do sistema e o microfone num só fluxo estéreo a 48 kHz, codificado em AAC ao lado do vídeo H.264. Como o zoom e a bolha são desenhados aqui, mudar um zoom mais tarde significa apenas renderizar de novo a partir dos mesmos ficheiros.
A mesma composição alimenta também a pré-visualização. Enquanto a exportação ainda está a correr, o leitor mostra a composição em direto, por isso pode ver o vídeo logo a seguir a parar.
Começámos por exportar em HEVC e passámos para H.264. Os ficheiros são um pouco maiores, mas reproduzem-se em qualquer browser, no computador de qualquer cliente, o que conta mais num link para partilhar do que alguns megabytes.
O encoder é o gargalo
Contávamos que o desenho fosse a parte lenta. Não é. Num M1, o encoder de hardware H.264 chega a cerca de 85 fotogramas por segundo em 2880×1800. Uma gravação de dez minutos a 30 fps são 18 000 fotogramas, por isso, mesmo no melhor dos casos, a exportação de um vídeo Retina demora cerca de três minutos e meio.
A prova de que o desenho não é o problema: quando acrescentámos o zoom automático com desfocagem de movimento e mistura de fotogramas nas mudanças de zoom, a velocidade de exportação não mudou. O Core Image na GPU acompanha sem esforço; quem dita o ritmo é o encoder.
Por isso, as alavancas estão no encoder e na espera, não no código de desenho. A maior para os utilizadores é a opção de resolução em .
| Alavanca | Efeito |
|---|---|
| Full HD em vez de Retina | Menos píxeis por fotograma para o encoder |
| Captura a 30 fps | Metade dos fotogramas de 60 fps |
| Mostrar a composição em direto na pré-visualização | Não tem de esperar para ver |
| Carregar só depois da exportação | Um ficheiro, um carregamento, sem nova codificação no servidor |
Armadilhas que não tinham a ver com a captura
A permissão de gravação do ecrã que voltava sempre
O macOS continuava a pedir permissão de gravação do ecrã mesmo depois de a termos dado. A causa: uma cópia de debug antiga, com assinatura ad-hoc e o mesmo bundle id, continuava no disco e tinha a autorização. O sistema via duas apps com o mesmo nome e assinaturas diferentes.
A solução foi apagar as cópias a mais e repor a permissão:
tccutil reset ScreenCapture dev.wearenube.rec
A lição é sobre assinatura, não sobre o ScreenCaptureKit: assine todas as builds da mesma forma e nunca distribua builds ad-hoc. Se é um utilizador preso no mesmo ciclo, o nosso guia sobre a permissão de gravação do ecrã que não funciona no Mac explica os passos sem o Terminal.
Um Mac bloqueado grava o fundo da secretária
Se o ecrã bloquear enquanto grava, o vídeo mostra apenas o fundo da secretária. Nada falha e não aparece nenhum erro: fica simplesmente com minutos de imagem de fundo. Mantenha o Mac acordado e não o deixe bloquear enquanto grava. É o tipo de falha que só se nota quando o cliente a nota.
O VideoPlayer do SwiftUI dava crash
As nossas vistas de leitor usavam primeiro o VideoPlayer do SwiftUI. Compilado com o SDK do Xcode 26, dava crash no macOS 15. Passámos a envolver o AVPlayerView do AppKit e os crashes pararam:
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 }
}
}
Ficar sem disco
Ficheiros separados mais uma exportação significam que uma gravação ocupa, durante algum tempo, mais do dobro do tamanho final. O JustRec não começa a gravar com menos de 2 GB livres, e para e guarda se o espaço livre descer abaixo de 500 MB durante uma gravação.
O que voltaríamos a fazer
Se está a construir sobre o ScreenCaptureKit
- Escolha o macOS mínimo pelas APIs de que precisa, e não ao contrário.
- Ponha todas as fontes num só relógio desde o primeiro dia; acrescentar a sincronização depois é doloroso.
- Guarde as fontes em bruto e renderize na exportação, se alguma coisa tiver de continuar editável.
- Meça o encoder antes de otimizar o código de desenho.
- Assine de forma consistente, mesmo em desenvolvimento, e apague as builds antigas.
- Teste com o ecrã bloqueado, o disco cheio e um microfone que muda a meio da gravação.
Também voltaríamos a começar com o SCRecordingOutput. Deu-nos depressa um protótipo a funcionar e mostrou-nos exatamente que limites contavam para o nosso produto.
A faixa de microfone limpa tem uma segunda vida: é a partir dela que se fazem as legendas. Essa história está em como executamos o Whisper no Mac.
Para o que acontece depois do carregamento, veja quanto custa alojar vídeos de clientes no Cloudflare R2. Para o lado da bolha da câmara que o utilizador vê, o nosso guia para gravar o ecrã e a câmara ao mesmo tempo mostra-a na prática, e a lista de funcionalidades mostra o resultado de toda esta maquinaria.
Fontes
- Apple Developer: ScreenCaptureKit, consultado em novembro de 2026.
- Apple Developer: SCStreamConfiguration.captureMicrophone (macOS 15.0), consultado em novembro de 2026.
- Apple Developer: SCStreamConfiguration.microphoneCaptureDeviceID (macOS 15.0), consultado em novembro de 2026.
- Apple Developer: SCRecordingOutput (macOS 15.0), consultado em novembro de 2026.
- Apple Developer: SCFrameStatus, consultado em novembro de 2026.
- Apple Developer: AVVideoCompositing, consultado em novembro de 2026.
- Apple Developer: AVAssetReaderAudioMixOutput, consultado em novembro de 2026.
- Apple Developer: AVPlayerView, consultado em novembro de 2026.
Perguntas frequentes
O que é o ScreenCaptureKit?
O ScreenCaptureKit é a framework da Apple para captar o conteúdo do ecrã e o áudio no macOS. Escolhe monitores, janelas ou apps com um filtro de conteúdo, e ela entrega vídeo e áudio como sample buffers que a sua app pode processar ou escrever num ficheiro.
Que versão do macOS é precisa para captar o microfone com o ScreenCaptureKit?
O macOS 15. As opções captureMicrophone e microphoneCaptureDeviceID do SCStreamConfiguration, o tipo de saída de microfone e o SCRecordingOutput chegaram todos no macOS 15.0, e é por isso que o JustRec exige o macOS 15 Sequoia ou posterior.
Devo usar o SCRecordingOutput ou o AVAssetWriter?
O SCRecordingOutput é a forma mais rápida de obter um ficheiro acabado. Se precisa de faixas separadas, de uma câmara vinda de outra sessão de captura, de gerir pausas ou de efeitos que possa editar depois de gravar, escrever os sample buffers por conta própria com o AVAssetWriter dá-lhe esse controlo.
Porque é que uma gravação com ScreenCaptureKit só mostra o fundo da secretária?
Nos nossos testes, isto aconteceu quando o Mac estava bloqueado durante a gravação: o vídeo mostrava o fundo da secretária em vez das apps. Mantenha o Mac acordado, e não o deixe bloquear, enquanto grava.
Porque é que o macOS está sempre a pedir permissão de gravação do ecrã durante o desenvolvimento?
A permissão está ligada à identidade e à assinatura da app. Se uma cópia antiga com o mesmo bundle id mas outra assinatura tiver a autorização, a build nova continua a pedir. Apague as cópias a mais, reponha a permissão com o tccutil e assine todas as builds da mesma forma.



