| Files | Last commit | Last update |
|---|---|---|
| 2 days ago | ||
| 10 days ago | ||
| 7 days ago | ||
| 10 days ago |
capture-core
Screen recording for macOS 15+ on ScreenCaptureKit. A host describes a recording as a Plan;
the core records each display, window or display region to its own H.264 MP4, places everything
it writes on one clock (Mach host time in nanoseconds), and describes the result in a versioned
capture.json manifest.
The crate compiles only on macOS. In this repository it sits outside the workspace and is built
only through the capture feature (scripts/check lints and tests it as -p capture-core).
Linux, Windows, Nix and release builds never compile it.
API
use capture_core::*;
let (input, input_rx) = host_channel(1024, KeyPolicy::Chords);
let (frames, frame_rx) = FrameTap::new(8, Duration::from_millis(500), 640);
let mut recording = Recorder::start(Plan {
sources: vec![
SourcePlan { source: Source::Display(display_id), long_edge: Some(2560) },
SourcePlan { source: Source::Window(window_id), long_edge: Some(1280) },
],
cursor: CursorDrawing::Hidden,
audio: AudioPlan { mic: Mic::External(ExternalMic::default()), system: false },
input: Input::Host(input_rx),
frames: Some(frames),
exclude: Exclusions { windows: vec![my_overlay_window], apps: vec![] },
fps: 60,
dir: bundle_dir,
names: Names {
videos: vec!["recording.mp4".into(), "window.mp4".into()],
microphone: Some("audio.wav".into()),
cursor: Some("cursor.json".into()),
input: Some("input.json".into()),
..Names::default()
},
})?;
let mut mic = recording.external_audio().unwrap(); // ExternalAudioSink
mic.offer(&pcm_16k_mono, HostTime::from_nanos(core_audio_ns)); // never blocks
mic.gap(from, through); // the host lost audio
input.send(HostTime::now(), event); // never blocks
let finishing = recording.stop(); // returns at once
// ... frame_rx already holds the last frame of each source
let finished = finishing.wait()?; // or poll with finishing.try_wait()
Recorder::start(Plan) -> Result<Recording, Error>: validates the plan, refuses names that already exist indir, and starts sources1..nbefore source0, which owns audio.Source:Display(id),Window(id), orRegion { display, rect }, a rect in the display's own points from its top-left corner (the stream'ssourceRect), trimmed to the display.SourcePlan::long_edge: the video's longest side in pixels, keeping the aspect and never above the source's own pixels.Nonerecords at full resolution. Both sides are rounded to even.screenshot(&Source, &Exclusions, long_edge) -> Result<Image, Error>: one RGBA still throughSCScreenshotManager, with the same filter and size a recording of that source would use.Recording:stop()andcancel()(which removes every file) return aFinishingat once.mark(label)andmark_at(host_time, label)record named moments on the bundle's clock.health()reports output failures and system stream stops as they happen.external_audio()hands out the microphone sink once.click_status()reports the optional tap. Dropping aRecordingstops it and keeps its files.Finishing:wait()blocks;try_wait()andis_finished()poll. Per-source problems are in the result, not in theErr: a partly failed recording still returns every file that survived.Finished: theManifest, per-source cursor telemetry, the input log, and any errors outside a single source.
Audio
Mic::None: no microphone. ScreenCaptureKit's microphone capture stays off.Mic::Device { id, warmup }: ScreenCaptureKit opens the Core Audio device and records it as a track in the first source's MP4, attachedwarmupafter the stream starts (usuallyMIC_WARMUP, 1.5 s) so both tracks share an origin.startblocks for the warm-up; a host that cues playback on the start, like a voiceover, passes zero.Mic::External(ExternalMic): the host owns the microphone and pushes mono PCM (16 kHz by default) through anExternalAudioSink. The sink is bounded: when the queue is full, the audio is dropped and recorded as a gap instead of blocking. Gaps are padded with silence, so sample 0 of the WAV is the clock anchor and every sample stays at its true time. The manifest lists every gap. The core never opens a microphone in this mode.AudioPlan::system: system audio, captured on the first source's stream. It is always a track in that source's MP4 (ScreenCaptureKit's recording output records what the stream captures, mixing system audio and aMic::Devicemicrophone into one AAC track). WhenNames::system_audiois set, it is also written as a separate AAC file, cut at the stop. If no system audio arrives, that file is not left behind and the manifest does not list it.
Input
Input::Host(receiver): the host forwards events from its own tap throughhost_channel. Sending never blocks; a full queue drops events and counts them.Input::Tap: the core runs its own listen-onlyCGEventTapfor mouse buttons. It needs Input Monitoring; without it the manifest listsinput_monitoringundermissing_permissions.Input::None: no input log.- Keys are kept only as
⌘/⌃chords by default (KeyPolicy::Chords), by name, never as typed text. Every key event is dropped while secure event input is on (a password field has focus). - Naming
Names::cursorturns on a pointer sampler (~240 Hz on the host clock) that places the cursor in each source's frames. Button events from either input path become click telemetry.
Frame tap
FrameTap::new(capacity, interval, max_width) sends at most one downscaled RGBA frame per source
per interval through a bounded channel, dropping frames when it is full. The newest skipped
frame is sent the moment the recording stops, so the host can build a contact sheet right away.
The clock
Every timestamp the core produces is Mach host time (mach_absolute_time) in nanoseconds: the
clock of Core Audio host times, CGEvent timestamps and ScreenCaptureKit frame display times.
That covers stream starts, audio file starts and gaps, cursor samples and clicks (host_ns, next
to their source_time in the video), input events (host_ns) and tapped frames
(Frame::host_time). A host on the same clock lines its own timestamps up without converting.
HostTime::from_nanos and HostTime::as_nanos cross the API boundary.
The bundle
The host picks every file name. The core adds only the manifest, capture.json unless
Names::manifest names it (a host that keeps several recordings in one directory names it per
recording):
{
"version": 1,
"clock": "mach_host_ns",
"anchor_ns": 81234000000000,
"started_at": "2026-10-30T18:05:12.345Z",
"sources": [{
"source": { "kind": "region", "display": 1, "rect": { "x": 100.0, "y": 80.0, "width": 640.0, "height": 400.0 } },
"file": "recording.mp4",
"start_ns": 81234041000000, "duration_ms": 5000, "width": 1280, "height": 800,
"scale": 2.0,
"frame": { "x": 100.0, "y": 80.0, "width": 640.0, "height": 400.0 },
"audio_tracks": []
}],
"files": [
{ "name": "recording.mp4", "kind": { "type": "video", "source": 0 }, "start_ns": 81234041000000 },
{ "name": "audio.wav", "kind": { "type": "microphone", "sample_rate": 16000 }, "start_ns": 81234000000000 }
],
"markers": [{ "host_ns": 81235000000000, "label": "intro" }],
"audio_gaps": [{ "start_ns": 81235200000000, "end_ns": 81235450000000 }],
"missing_permissions": []
}
anchor_ns is taken just before the first source starts; sample 0 of the microphone WAV sits
there, and started_at records the UTC wall-clock time at anchor_ns for ordering takes across
reboots. A media file's start_ns is where its time zero sits; the cursor and input logs carry
host_ns per entry instead, and can be read back with CursorBundle::read_file and
InputBundle::read_file. External window-layout and transcript sidecars can also be listed in
files as windows or transcript (audio_file optional). A source's frame is the captured
area in global points, scale is the backing pixels-per-point factor when opened, label records
any window title, and error is set when it did not finish cleanly. Manifest::read (or
Manifest::read_file for a named one) refuses a manifest newer than it understands.
Seams
- The host chooses sources, exclusions, file names, and where audio and input come from. The core excludes nothing on its own, not even its host's windows.
ExternalAudioSink,HostInputSenderandFrameTapare bounded queues between the host's threads and the core's. None of them blocks the sender.- Inside the crate, each source's native stream sits behind a small trait so the stop sequence is tested against a fake stream.
Invariants
- No global state, no servers, no shortcuts. Threads start with a recording and end with its finishing, with one exception: a stream stop that never returns (below) leaves its thread blocked inside ScreenCaptureKit.
stop()returns at once (under 10 ms, tested). Finishing runs on the recording's own thread.- Stopping cuts every source before waiting on any of them. A source's stream keeps running until its detached recording output reports that it finished or failed: stopping a stream while ScreenCaptureKit is still exporting a removed output can make the export run twice and abort, losing the file. Only then is the stream stopped, and that stop is bounded at 10 s, because the capture service can stop answering.
- A system stream stop is not an output failure. If the output still finishes, the MP4 is kept.
- ScreenCaptureKit writes a frame only when the screen changes, so a video track can end at the last change while audio runs to the stop. The final frame is held to the recorded end by lengthening its sample in place, so every reader sees the full length. When B-frames decode the final frame before another, a skipped H.264 picture that copies it exactly is appended instead; no existing sample is re-encoded or moved. The video's end is where its frames stop, even when the edit list claims more, as it can for a video-only source. If either fails, the original file is kept.
- Logging goes through
tracing.
Tests
scripts/check runs them with everything else. For this crate alone:
cargo nextest run -p "$(cargo pkgid)" -p capture-core --features capture -E 'not package(hex)'
The final-frame tests write their fixtures with AVAssetWriter and ffmpeg, and check them with
AVFoundation and ffprobe (both must be on PATH). Tests that need Screen Recording permission
and a live display are #[ignore]d.