scrcap 0.1.0-alpha.1

Screen and system-audio capture for Windows, macOS and Linux
docs.rs failed to build scrcap-0.1.0-alpha.1
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Visit the last successful build: scrcap-0.1.0-alpha.2

About

A screen capture crate for Windows/macOS/Linux.

Usage

A capture is a [CaptureConfig] you create(), and a [CaptureDescriptor] you pull frames from. Video and audio get a channel each.

Seeing it work

The quickest way to watch a capture is the winit example, a preview window that draws the captured frames into itself:

cargo run --example winit

It opens an always-on-top window, waits until that window has drawn a frame of its own, and only then raises the system picker. Pick a source and it appears in the window; Esc quits. On Windows and macOS the preview hides itself from the capture through [VideoConfig::hide], on Linux no protocol allows that, so expect exactly that.

For somewhere with no display and no picker, cargo test --features dummy swaps the whole platform module for a fake backend that generates frames, and runs the capture test headless.

Video only

size() is known by the time create returns; every frame carries its own size, pixel format and capture timestamp anyway.

use scrcap::{CaptureConfig, CaptureDescriptor as _, Target, VideoConfig, error::Result};

fn main() -> Result<()> {
    let capture = CaptureConfig {
        video: VideoConfig {
            // At least 1. A full channel drops frames rather than blocking the capture.
            channel_capacity: 2,
            // Window ids to keep out of the recording (`HWND` / `CGWindowID`).
            hide: vec![],
            target: Target::Primary,
            // A cap, not a target: it cannot raise a rate the machine cannot sustain.
            fps: Some(60),
        },
        audio: None,
    }
    .create()?;

    let (width, height) = capture.size();
    println!("capturing {width}x{height}");

    // The origin of `ts` is platform defined, so subtract the first one of the session.
    let mut start = None;
    for _ in 0..300 {
        // `Err` only once the capture thread is gone.
        let frame = capture.video().recv().expect("the capture ended");
        let start = *start.get_or_insert(frame.ts);
        // BGRA on Windows and macOS, BGRA *or* BGRx on Linux -- read `pix_fmt`, do not
        // assume. Either way `bytes_per_pixel() * width * height` bytes.
        println!(
            "{:?} {}x{} at {} ms",
            frame.pix_fmt,
            frame.size.0,
            frame.size.1,
            (frame.ts - start) / 1_000_000
        );
    }

    // `Drop` terminates too; this just does it early.
    capture.terminate();
    Ok(())
}

Video and audio

Two channels means two receivers to drain, so take whichever has a frame ready. Give audio the deeper capacity of the two. sample_rate() answers None until the system has settled the format, which on Linux and macOS happens after create returns -- every [AudioFrame] carries its own rate, so the first frame always answers it.

use crossbeam_channel::select;
use scrcap::{AudioConfig, CaptureConfig, CaptureDescriptor as _, Target, VideoConfig, error::Result};

fn main() -> Result<()> {
    let capture = CaptureConfig {
        video: VideoConfig {
            channel_capacity: 2,
            hide: vec![],
            target: Target::Primary,
            fps: Some(60),
        },
        audio: Some(AudioConfig {
            channel_capacity: 64,
        }),
    };
    let capture = capture.create()?;
    let audio = capture.audio().expect("audio was configured");

    loop {
        select! {
            recv(capture.video()) -> frame => match frame {
                Ok(frame) => println!("video: {} bytes", frame.vframe.len()),
                Err(_) => break, // the capture ended
            },
            recv(audio) -> frame => match frame {
                Ok(frame) => println!(
                    "audio: {} samples of {:?} at {} Hz",
                    frame.nb_samples, frame.sample_fmt, frame.sample_rate
                ),
                Err(_) => break,
            },
        }
    }
    Ok(())
}

Letting the user choose

Target::Pick raises the system picker and blocks until the user has chosen, so it must not run on the thread driving the UI: the Windows picker is parented to the HWND you pass, whose message pump would be the one blocked, and the macOS picker answers on the main queue. Linux always shows the portal's picker, whatever the target, and only Primary/Monitor (monitors only) and Pick (everything) narrow what it offers.

use std::thread;
use scrcap::{CaptureConfig, CaptureDesc, Target, VideoConfig, error::Result};

fn pick(
    #[cfg(target_os = "windows")] parent_hwnd: isize,
) -> thread::JoinHandle<Result<CaptureDesc>> {
    // Only `GraphicsCapturePicker` needs a parent window, so `Pick` carries one on Windows
    // and is a unit variant elsewhere.
    #[cfg(target_os = "windows")]
    let target = Target::Pick(parent_hwnd);
    #[cfg(not(target_os = "windows"))]
    let target = Target::Pick;

    thread::spawn(move || {
        CaptureConfig {
            video: VideoConfig {
                channel_capacity: 2,
                hide: vec![],
                target,
                fps: Some(60),
            },
            audio: None,
        }
        .create()
    })
}

Errors worth matching

CaptureError carries no free-form strings: every case a caller can act on is a variant. A missing capability is Unsupported, which is usually worth retrying without the feature that asked for it -- audio needs macOS 13+, and on Linux a connection to the PipeWire daemon that a sandbox holding only the portal's screencast remote does not have.

use scrcap::{
    AudioConfig, CaptureConfig, CaptureDesc, Target, VideoConfig,
    error::{CaptureError, Result, Unsupported},
};

/// `Ok(None)` when the user changed their mind; everything else is a real failure.
fn create() -> Result<Option<CaptureDesc>> {
    let config = |audio: bool| CaptureConfig {
        video: VideoConfig {
            channel_capacity: 2,
            hide: vec![],
            target: Target::Primary,
            fps: Some(60),
        },
        audio: audio.then(|| AudioConfig {
            channel_capacity: 64,
        }),
    };

    match config(true).create() {
        // This machine cannot capture audio at all; the video alone still works.
        Err(CaptureError::Unsupported(Unsupported::Audio)) => config(false).create().map(Some),
        // The user dismissed the picker or the permission prompt: not a malfunction,
        // so do not re-raise the picker in their face.
        Err(CaptureError::Cancelled) => Ok(None),
        // macOS only, and no retry will clear it: Screen Recording has to be granted in
        // System Settings > Privacy & Security, and only takes effect after a restart.
        Err(e @ CaptureError::PermissionDenied) => {
            eprintln!("grant Screen Recording, then restart this app");
            Err(e)
        }
        result => result.map(Some),
    }
}

To restart a running capture with a different configuration, use [CaptureDescriptor::update_config] rather than creating a second one: it takes the old descriptor by value and drops it first, so the old capture cannot restore the very windows the new one just hid.

Features

  • dummy: A dummy implementation that returns random 1080x720 images (AV_PIX_FMT_BGRA). No audio is produced.

AI Involvement

In this repo, commits by @kingwingfly are manually coded, while those by @kingwingfly-ai are coded by agents (Claude + OpenAI).