ES

ScreenCaptureKit: lo que aprendimos creando un grabador para Mac

Cómo creamos un grabador nativo para Mac sobre ScreenCaptureKit: escritores separados con un solo reloj, un compositor Core Image, límites del codificador y errores.

Nicolas Ricardi9 min de lectura

En resumen

  • Empezamos con SCRecordingOutput y lo sustituimos por nuestros propios AVAssetWriter: un archivo por fuente, todos sincronizados con un único reloj compartido.
  • La burbuja de cámara, el cursor y el zoom se dibujan al exportar con un compositor AVVideoCompositing propio, así que siguen siendo editables después de grabar.
  • En un M1, el codificador H.264 por hardware llega como máximo a unos 85 fps a 2880×1800: la velocidad de exportación la marca el codificador, no el dibujo.
  • La mayoría de nuestros bugs más difíciles no estaban en el código de captura: permisos ligados a firmas, una pantalla bloqueada, un fallo del reproductor de SwiftUI.

ScreenCaptureKit es el framework de macOS para capturar la pantalla, y hace bien ese único trabajo: te entrega fotogramas y audio. Los archivos, la sincronización, la cámara, la exportación y los permisos te tocan a ti. Esto es lo que aprendimos al construir sobre él JustRec, un grabador nativo para la barra de menús.

La versión corta: empezamos con la salida de grabación integrada de Apple, pasamos a nuestros propios escritores, que guardan cada fuente en su propio archivo con un único reloj compartido, y ahora dibujamos lo demás al exportar. Los bugs más difíciles no tenían nada que ver con la captura.

  1. Primera versión

    SCRecordingOutput

    Un flujo, un MP4 terminado. La forma más rápida de tener un prototipo que funciona.

  2. Qué se rompió

    Un archivo no bastaba

    La cámara, un zoom editable y una pista de voz limpia para los subtítulos no cabían en un único archivo grabado.

  3. Segunda versión

    Escritores separados con un solo reloj

    La pantalla, la cámara, el audio del sistema y el micrófono van cada uno a su propio AVAssetWriter, sincronizados por un único reloj de captura compartido.

  4. Tercera versión

    Un compositor al exportar

    La burbuja, el cursor, los clics y el zoom se dibujan con Core Image cuando pulsas parar, así que siguen siendo editables.

Por qué exigimos macOS 15

JustRec funciona en macOS 15 Sequoia o posterior. No fue una decisión de marketing. En macOS 15, ScreenCaptureKit incorporó la captura del micrófono: captureMicrophone y microphoneCaptureDeviceID en SCStreamConfiguration, además de un tipo de salida .microphone en el flujo. SCRecordingOutput llegó en la misma versión.

Capturar el micrófono dentro del flujo significa que el audio del sistema y tu voz vienen de la misma fuente, con marcas de tiempo del mismo reloj. En sistemas anteriores tendrías que hacer una captura de audio aparte y alinearla tú. Dejar fuera macOS 14 nos cuesta algunos usuarios potenciales y nos ahorra toda una familia de bugs de sincronización.

let config = SCStreamConfiguration()
config.minimumFrameInterval = CMTime(value: 1, timescale: 30)
config.capturesAudio = true                    // audio del sistema
config.excludesCurrentProcessAudio = true      // sin nuestros propios sonidos
config.captureMicrophone = true                // macOS 15
config.microphoneCaptureDeviceID = microphoneID // macOS 15

Primera versión: SCRecordingOutput

Nuestro primer prototipo usaba SCRecordingOutput. Lo conectas al flujo, le das la URL de un archivo y obtienes un MP4 al detener la grabación. Para una grabación de pantalla sin más, es el camino más rápido hacia una app que funciona, y lo recomendaríamos si es todo lo que necesitas.

Dejó de encajar cuando tuvimos claro lo que debía hacer el producto:

  • La cámara viene de otro sitio. La burbuja de cámara es una AVCaptureSession (cámara integrada, webcam externa o un iPhone con Cámara de continuidad), no forma parte del flujo de pantalla.
  • Queríamos la burbuja fuera de la captura de pantalla. Si la ventana de la burbuja queda grabada dentro de los fotogramas de pantalla, un zoom automático también la amplía. Queríamos hacer zoom sobre la pantalla y mantener la burbuja del mismo tamaño.
  • Los subtítulos necesitan una pista de voz limpia. La transcripción funciona mejor solo con el micrófono, sin los sonidos del sistema mezclados, y puede empezar antes de que exista el vídeo final.
  • Los efectos tienen que seguir siendo editables. El zoom automático, el resaltado de clics y el cursor se pueden cambiar después de grabar, así que no se pueden incrustar mientras grabas.

Un único archivo terminado no podía darnos nada de eso, así que escribimos nuestros propios escritores.

Qué cambió al dejar SCRecordingOutput
SCRecordingOutput (primera versión) Nuestros propios escritores (ahora)
Salida Un MP4 terminado Un archivo por fuente, más events.json
Cámara desde una AVCaptureSession ✗ no forma parte del flujo ✓ en su propio archivo, con el mismo reloj
Zoom sin ampliar la burbuja ✗ burbuja incrustada en los fotogramas ✓ burbuja dibujada después del zoom
Pista de voz para subtítulos ✗ espera al archivo terminado ✓ micrófono en su propia pista
Efectos editables después de grabar ✗ ✓ se vuelven a renderizar desde los mismos archivos
Código que escribir y mantener Muy poco Escritores, reloj, compositor, exportador

Segunda versión: escritores separados con un solo reloj

Cada fuente va ahora a su propio AVAssetWriter: screen.mp4, camera.mp4, una pista de audio del sistema y una pista de micrófono, más un events.json con la posición del ratón (muestreada 60 veces por segundo), los clics, la posición y la forma de la burbuja, y las imágenes del cursor. Todo vive en una carpeta de proyecto; el MP4 que compartes se renderiza a partir de ella.

La regla que lo hizo funcionar: cada muestra toma su tiempo de un único reloj de captura compartido, basado en el reloj de tiempo del host. El reloj también gestiona las pausas. Los fotogramas que llegan durante una pausa se descartan, y la duración de cada pausa anterior se resta de las marcas de tiempo posteriores, así que los archivos no tienen huecos.

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

La cámara funciona en su propia sesión de captura con su propio reloj, así que sus marcas de tiempo se convierten a tiempo del host antes de llegar al reloj compartido:

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

Algunos detalles que nos costaron tiempo:

  • Los archivos empiezan tarde. La cámara puede entregar su primer fotograma medio segundo después que la pantalla. Cada archivo registra su hora de inicio real y, al exportar, se coloca con ese desfase, nunca en cero. En el vídeo, el primer fotograma se mantiene hasta que el archivo empieza de verdad, así que no hay un destello negro.
  • Los formatos de audio cambian a mitad de grabación. Si cambias de micrófono mientras grabas, el formato de las muestras puede cambiar. En lugar de pasarle un formato nuevo al escritor anterior, cerramos el archivo y abrimos mic-2.m4a en el momento actual. El exportador vuelve a poner las piezas en orden.
  • Escribe solo fotogramas completos. ScreenCaptureKit adjunta un estado a cada fotograma. Solo escribimos los marcados como .complete y nos saltamos el resto.
  • Cierra bien el último fotograma. Si no se mueve nada en pantalla, el último fotograma puede llegar mucho antes de que pulses parar. Añadimos una copia justo antes de la hora de fin, para que el vídeo dure lo mismo que la grabación.

Tercera versión: un compositor al exportar

Cuando pulsas parar, montamos una AVMutableComposition con los archivos del proyecto y la renderizamos con un compositor AVVideoCompositing propio. Para cada fotograma de salida recibe el fotograma de pantalla y el de cámara, y dibuja el resultado con Core Image: el fondo y los márgenes, los pulsos de los clics, un cursor redibujado, la cámara virtual del zoom automático y, por último, la burbuja de cámara, recortada en círculo, cuadrado o 16:9, con borde y sombra.

El audio pasa por un exportador de lectura y escritura: un AVAssetReaderAudioMixOutput mezcla el audio del sistema y el micrófono en un único flujo estéreo a 48 kHz, que se codifica en AAC junto al vídeo H.264. Como el zoom y la burbuja se dibujan aquí, cambiar un zoom más tarde solo significa volver a renderizar a partir de los mismos archivos.

La misma composición alimenta también la vista previa. Mientras la exportación sigue en marcha, el reproductor muestra la composición en directo, así que puedes ver el vídeo nada más detener la grabación.

Empezamos exportando en HEVC y cambiamos a H.264. Los archivos son algo más grandes, pero se reproducen en cualquier navegador del ordenador de cualquier cliente, y eso importa más en un enlace para compartir que unos pocos megas.

El cuello de botella es el codificador

Esperábamos que la parte lenta fuera el dibujo. No lo es. En un M1, el codificador H.264 por hardware llega como máximo a unos 85 fotogramas por segundo a 2880×1800. Una grabación de diez minutos a 30 fps son 18 000 fotogramas, así que, incluso en el mejor caso, exportar un vídeo Retina lleva unos tres minutos y medio.

La prueba de que el dibujo no es el problema: cuando añadimos el zoom automático con desenfoque de movimiento y fusión de fotogramas durante los cambios de zoom, la velocidad de exportación no cambió. Core Image en la GPU va sobrado; el ritmo lo marca el codificador.

Así que las palancas tienen que ver con el codificador y con la espera, no con el código de dibujo. La más importante para los usuarios es el selector de resolución de AjustesVídeo.

Lo que de verdad acorta la espera
Palanca Efecto
Full HD en lugar de Retina Menos píxeles por fotograma para el codificador
Captura a 30 fps La mitad de fotogramas que a 60 fps
Mostrar la composición en directo en la vista previa No tienes que esperar para verlo
Subir solo después de exportar Un archivo, una subida, sin recodificar en el servidor

Tropiezos que no eran de captura

El permiso de grabación de pantalla que volvía una y otra vez

macOS seguía pidiendo permiso de grabación de pantalla incluso después de haberlo concedido. La causa: una copia de depuración antigua, firmada ad hoc y con el mismo bundle id, seguía en el disco y tenía el permiso. El sistema veía dos apps con un mismo nombre y firmas distintas.

La solución fue eliminar las copias que sobraban y restablecer el permiso:

tccutil reset ScreenCapture dev.wearenube.rec

La lección va de firmas, no de ScreenCaptureKit: firma todas las compilaciones de la misma forma y no distribuyas nunca compilaciones ad hoc. Si eres usuario y estás atrapado en el mismo bucle, nuestra guía para arreglar el permiso de grabación de pantalla en el Mac explica los pasos sin necesidad de Terminal.

Un Mac bloqueado graba el fondo de pantalla

Si la pantalla se bloquea mientras grabas, el vídeo muestra solo el fondo de pantalla. No se cuelga nada ni salta ningún error: simplemente obtienes minutos de fondo de escritorio. Mantén el Mac activo y no dejes que se bloquee mientras grabas. Es el tipo de fallo que solo descubres cuando lo descubre el cliente.

El VideoPlayer de SwiftUI se colgaba

Nuestras vistas de reproducción usaban al principio el VideoPlayer de SwiftUI. Compilado con el SDK de Xcode 26, se colgaba en macOS 15. Lo cambiamos por un AVPlayerView de AppKit envuelto y los cuelgues desaparecieron:

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

Quedarse sin disco

Archivos separados más una exportación significan que, durante un rato, una grabación ocupa más del doble de su tamaño final. JustRec no empieza a grabar con menos de 2 GB libres, y detiene y guarda la grabación si el espacio libre baja de 500 MB mientras grabas.

Lo que volveríamos a hacer

Si vas a construir sobre ScreenCaptureKit

  • Elige tu macOS mínimo según las API que necesitas, no al revés.
  • Pon todas las fuentes en un mismo reloj desde el primer día; añadir la sincronización después es un suplicio.
  • Conserva las fuentes en bruto y renderiza al exportar si algo debe seguir siendo editable.
  • Mide el codificador antes de optimizar tu código de dibujo.
  • Firma siempre igual, también en desarrollo, y elimina las compilaciones antiguas.
  • Prueba con la pantalla bloqueada, con el disco lleno y con un micrófono que cambia a mitad de grabación.

También volveríamos a empezar con SCRecordingOutput. Nos dio un prototipo funcional muy rápido y nos enseñó exactamente qué limitaciones importaban para nuestro producto.

La pista limpia del micrófono tiene una segunda vida: es de donde salen los subtítulos. Esa historia está en Whisper en el Mac: subtítulos sin la nube.

Para lo que pasa después de la subida, consulta cuánto cuesta alojar vídeos de clientes en Cloudflare R2. Para el lado de la burbuja de cámara que ve el usuario, nuestra guía para grabar la pantalla y la cámara a la vez lo muestra en la práctica, y la lista de funciones enseña en qué se traduce toda esta maquinaria.

Fuentes

Preguntas frecuentes

¿Qué es ScreenCaptureKit?

ScreenCaptureKit es el framework de Apple para capturar el contenido de la pantalla y el audio en macOS. Eliges pantallas, ventanas o apps con un filtro de contenido, y te entrega el vídeo y el audio como sample buffers que tu app puede procesar o escribir en un archivo.

¿Qué versión de macOS necesitas para capturar el micrófono con ScreenCaptureKit?

macOS 15. Las opciones captureMicrophone y microphoneCaptureDeviceID de SCStreamConfiguration, el tipo de salida de micrófono y SCRecordingOutput llegaron todos con macOS 15.0, y por eso JustRec requiere macOS 15 Sequoia o posterior.

¿Uso SCRecordingOutput o AVAssetWriter?

SCRecordingOutput es la forma más rápida de obtener un archivo terminado. Si necesitas pistas separadas, una cámara de otra sesión de captura, gestionar las pausas o efectos que se puedan editar después de grabar, escribir tú los sample buffers con AVAssetWriter te da ese control.

¿Por qué una grabación con ScreenCaptureKit muestra solo el fondo de pantalla?

En nuestras pruebas pasaba cuando el Mac se bloqueaba durante la grabación: el vídeo mostraba el fondo de pantalla en lugar de las apps. Mantén el Mac activo, y no dejes que se bloquee, mientras grabas.

¿Por qué macOS no deja de pedir permiso de grabación de pantalla durante el desarrollo?

El permiso está ligado a la identidad y la firma de la app. Si una copia antigua con el mismo bundle id pero otra firma tiene el permiso, la nueva compilación no deja de pedirlo. Elimina las copias que sobran, restablece el permiso con tccutil y firma todas las compilaciones de la misma forma.

Escrito por

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
Construido en público