DEV Community

Cover image for I needed cross-platform screen capture in Rust, so I built pinray
Rupam Golui
Rupam Golui Subscriber

Posted on

I needed cross-platform screen capture in Rust, so I built pinray

So I needed screen capture in Rust. Simple enough, right?

Wrong.

I went looking for a crate and found three options: scap, xcap, and waycap-rs. Each one had problems that drove me nuts.

So I open sourced pinray, a Rust crate for cross-platform screen and system audio capture.

The goal is simple: provide a single API over each platform's native capture APIs without depending on ffmpeg or another large capture framework. pinray focuses on capture only. It gives you raw video and audio frames with the metadata needed to build your own recording, streaming, or processing pipeline.

Repository: https://github.com/Itz-Agasta/pinray


Why I built it?

xcap

xcap has the best cross-platform story, but its frame type is a joke: just width, height, and raw bytes. No stride. No pixel format. No timestamps. You're flying blind.

scap

scap has a better frame model but it isnt maintain anymore. I got a lot of compile error due to its PipeWire 0.8.0 dependency chain on my arch. Issue #185

Its Linux engine is also one giant struct with #[cfg] fields scattered throughout, which makes extending it painful.

waycap-rs

waycap-rs is probably the strongest Wayland implementation. But it's Wayland-only. And it shells out to pactl for audio device discovery.

In a library. In 2026.

None of them gave me what I actually wanted:

  • A clean separation between capture backends and the public API
  • A frame model with enough metadata to do real work

Focuse mode on

What pinray does

pinray is a capture infrastructure crate.

It talks directly to each operating system's native capture APIs and delivers raw frames together with their metadata, including timestamps, pixel format, stride, sequence numbers, and dropped-frame notifications.

Encoding is intentionally out of scope. The output can be sent to ffmpeg, WebRTC, wgpu, the image crate, or any custom processing pipeline.

The current native backends are:

Platform Video Audio
Linux (Wayland) XDG Desktop Portal + PipeWire PipeWire
Linux (X11) XGetImage polling PipeWire
macOS 12.3+ ScreenCaptureKit ScreenCaptureKit
Windows 10+ DXGI Desktop Duplication + Windows Graphics Capture WASAPI Loopback

No wrapper crates are used around these platform APIs.


Basic usage

Capturing the primary display together with system audio looks like this:

use std::time::Duration;
use pinray::{AudioCapture, CaptureEvent, CaptureSession, SourceId, VideoCaptureTarget};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut session = CaptureSession::builder()
        .video_target(VideoCaptureTarget::Display(SourceId::new("auto")))
        .audio(AudioCapture::SystemMix)
        .build()?;

    session.start()?;

    loop {
        match session.next_event(Some(Duration::from_secs(5)))? {
            CaptureEvent::Video(frame) => {
                println!("{}x{}", frame.width, frame.height);
            }
            CaptureEvent::Audio(frame) => {
                println!("{} Hz", frame.sample_rate);
            }
            CaptureEvent::Gap(gap) => {
                println!("Dropped frames: {:?}", gap.reason);
            }
            CaptureEvent::End => break,
        }
    }

    session.stop()?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The same API works across Linux, macOS, and Windows.

On Linux, Auto selects either the Wayland or X11 backend. On Windows it prefers DXGI Desktop Duplication and falls back to Windows Graphics Capture when appropriate. backend_info() can be used to inspect which backend was selected.

Window enumeration follows the same API:

use pinray::{CaptureSource, CaptureSession};

let sources = pinray::enumerate_sources()?;

let window = sources.iter().find_map(|source| match source {
    CaptureSource::Window(window) if window.title.contains("Firefox") => {
        Some(window.id.clone())
    }
    _ => None,
});
Enter fullscreen mode Exit fullscreen mode

Audio-only capture is equally straightforward:

let mut session = CaptureSession::builder()
    .audio(AudioCapture::SystemMix)
    .build()?;
Enter fullscreen mode Exit fullscreen mode

Design decisions

The most challenging part was not implementing screen capture for a single platform. It was exposing a consistent API over several fundamentally different capture systems.

Different capture models

Each platform delivers frames differently.

ScreenCaptureKit and Windows Graphics Capture continuously stream frames. DXGI Desktop Duplication only produces new frames when the desktop changes, so an idle desktop naturally results in timeouts. X11 has no event-driven capture API, so the backend polls at the requested frame rate.

Consistent timestamps

Every platform uses a different clock.

Windows exposes QPC ticks, macOS uses host time, and PipeWire exposes different timing information. pinray normalizes everything to monotonic nanosecond timestamps so audio and video from the same capture session share a common timeline.

Wayland support

Wayland intentionally requires user approval through the desktop portal.

Instead of trying to work around that, pinray embraces the portal workflow. SourceId::new("auto") opens the native picker, while restore tokens allow subsequent sessions to reuse previously granted permissions.

Native pixel formats

All current backends produce BGRA frames. If an application requests RGBA, pinray performs the conversion explicitly. There are no hidden format conversions.

Frame drops

Dropped frames are exposed as explicit Gap events, and every frame carries a sequence number. Applications that synchronize audio and video need this information, so it is surfaced directly instead of being hidden internally.


Getting started

cargo add pinray
Enter fullscreen mode Exit fullscreen mode

Linux requires libpipewire-0.3-dev and clang during compilation. No additional dependencies are required on macOS or Windows.

good luck

If you try pinray and run into an issue, feel free to open one on GitHub. Including the output of session.backend_info() is especially helpful since backend selection can differ between systems.

Top comments (0)