Expand description
Cross-platform screen and audio capture with raw frames and real metadata.
pinray captures displays, windows, and system audio through each OS’s
native API — Wayland portals + PipeWire and X11 on Linux,
ScreenCaptureKit on macOS, DXGI/Windows Graphics Capture + WASAPI on
Windows — and hands you raw VideoFrames and AudioFrames with
stride, pixel format, timestamps, and sequence numbers intact. Encoding
is deliberately out of scope: feed frames to ffmpeg, WebRTC, wgpu, or
your own pipeline.
CaptureSession— builder-configured lifecycle (start/next_event/stop);enumerate_sourceslists displays, windows, and audio devices up front.CaptureEvent— one ofVideoFrame,AudioFrame, aGapEvent(dropped frames / backend restart), orEnd.BackendPreference—Autopicks the right backend per platform;CaptureSession::backend_inforeports what was actually selected.
§Getting Started
pinray captures screens, windows, and system audio through each OS’s native API and hands you raw frames. This guide walks from install to your first frames. Full API reference: docs.rs/pinray.
docs.rs builds the
pinrayandpinray-platform-*crates for a fixed target per platform (docs.rs can’t link the native libraries for every OS at once, e.g. nolibpipewireon their Linux builders) - the badge you land on may say Windows or macOS even if you’re targeting Linux. The type definitions are identical on every platform regardless of which one the page was built for. For a build that’s never redirected, see docs.rs/pinray-core - it has no native dependencies and always builds on the plain default target.
§Install
cargo add pinrayLinux needs build-time system libraries (see platforms.md); macOS and Windows need nothing extra.
§Capture your first frames
use std::time::Duration;
use pinray::{CaptureEvent, CaptureSession, SourceId, VideoCaptureTarget};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut session = CaptureSession::builder()
.video_target(VideoCaptureTarget::Display(SourceId::new("auto")))
.build()?;
session.start()?;
for _ in 0..60 {
match session.next_event(Some(Duration::from_secs(5)))? {
CaptureEvent::Video(frame) => {
// frame.data is FrameData::Host(Vec<u8>) - packed rows of
// frame.stride bytes, frame.pixel_format (BGRA by default)
println!("{}x{} at t={}ns", frame.width, frame.height, frame.stream_time_ns);
}
CaptureEvent::Gap(gap) => eprintln!("dropped frames: {:?}", gap),
_ => {}
}
}
session.stop()?;
Ok(())
}SourceId::new("auto") targets the primary display. On Wayland the compositor shows a permission dialog instead - whatever the user picks there is what you capture.
§Pick a specific display or window
use pinray::{CaptureSource, CaptureSession, VideoCaptureTarget};
let sources = pinray::enumerate_sources().unwrap();
let window = sources.iter().find_map(|s| match s {
CaptureSource::Window(w) if w.title.contains("Firefox") => Some(w.id.clone()),
_ => None,
});
if let Some(id) = window {
let session = CaptureSession::builder()
.video_target(VideoCaptureTarget::Window(id))
.build();
}Window ids die with the window - enumerate right before building the session.
§Add system audio
use std::time::Duration;
use pinray::{AudioCapture, CaptureSession, SourceId, VideoCaptureTarget};
let mut session = CaptureSession::builder()
.video_target(VideoCaptureTarget::Display(SourceId::new("auto")))
.audio(AudioCapture::SystemMix)
.build()
.unwrap();
session.start().unwrap();Audio arrives as CaptureEvent::Audio interleaved with video from next_event, or drain it directly with session.next_audio(timeout).
§Audio only, no video
You don’t need a video_target at all - an audio-only session is a first-class use case, not a side effect of the video API, and it never shows a permission dialog on Linux/Windows:
use std::time::Duration;
use pinray::{AudioCapture, CaptureSession};
let mut session = CaptureSession::builder()
.audio(AudioCapture::SystemMix)
.build()?;
session.start()?;
let frame = session.next_audio(Some(Duration::from_secs(3)))?;
println!("{} Hz, {} ch", frame.sample_rate, frame.channels);
session.stop()?;Runnable version: cargo run --example audio_smoke.
Only AudioCapture::SystemMix (loopback / sink monitor - “everything the system plays”) is implemented today. AudioCapture::Microphone(SourceId) exists in the enum but no backend implements it yet - .build() itself fails with PinrayError::Unsupported, before a session is ever created. If you need mic input, pinray can’t do that yet; track backend support before building on this path.
§Tuning
use pinray::{BackendPreference, CaptureSession, CursorMode, PixelFormat, Rect};
let builder = CaptureSession::builder()
.backend_preference(BackendPreference::Auto) // or force WindowsWgc, LinuxX11, ...
.pixel_format(PixelFormat::Rgba8888) // BGRA is native everywhere; RGBA costs a swizzle
.cursor_mode(CursorMode::Hidden)
.crop_rect(Some(Rect { x: 0, y: 0, width: 1280, height: 720 }))
.frame_rate(Some(30))
.queue_depth(4); // frames buffered before dropsCheck what you actually got:
let info = session.backend_info();
println!("{:?}: audio={} notes={}", info.kind, info.supports_audio, info.notes);Include that output in bug reports - backend selection differs per machine.
§Timestamps, sequences, gaps
stream_time_nsis monotonic and comparable between a session’s audio and video streams. The epoch differs per platform (boot time on macOS/Windows, process-relative on Linux) - compute deltas, don’t compare across machines.sequenceincrements once per delivered frame per stream; a jump means the consumer fell behind and frames were dropped.CaptureEvent::Gapreports drops and backend restarts explicitly.
§Muxing frames into a video file
pinray deliberately doesn’t encode (see the crate docs) - piping VideoFrame/AudioFrame bytes into ffmpeg (or another encoder) is on you. Two mistakes are easy to make here and both produce a file that opens and plays fine while being silently wrong:
1. Row padding. stride can be wider than width * bytes_per_pixel (platform row alignment). Copying FrameData::Host bytes straight into a rawvideo pipe skews every row after the first into a diagonal smear whenever that happens. Always go through VideoFrame::to_tight_bytes instead of touching data directly:
if let CaptureEvent::Video(frame) = session.next_event(None)? {
let tight = frame.to_tight_bytes().expect("Host frame, packed pixel format");
// write `tight`, not `frame.data`, to your rawvideo pipe
}2. Frame rate. frame_rate on the builder is a request, not a guarantee - Wayland portals and other push-driven backends deliver frames when the compositor damages the screen, not on a fixed clock. If you declare a constant -framerate to ffmpeg’s rawvideo demuxer and just stream whatever arrives, a real-time recording plays back sped up or slowed down by however far the actual delivery rate was from what you declared, and -shortest will silently truncate whichever stream (usually audio) is longer. stream_time_ns is exactly what you need to fix this: track how many output frames are “due” by wall-clock delta and duplicate the last frame to fill the gap, instead of assuming one input frame equals one output frame:
let target_fps = 30i64;
let frame_interval_ns = 1_000_000_000 / target_fps;
let mut next_due_ns: Option<i64> = None;
let mut last_frame: Vec<u8> = Vec::new();
loop {
let CaptureEvent::Video(frame) = session.next_event(None)? else { continue };
let tight = frame.to_tight_bytes().expect("Host frame, packed pixel format");
let due = next_due_ns.get_or_insert(frame.stream_time_ns + frame_interval_ns);
while frame.stream_time_ns >= *due {
write_frame_to_pipe(if last_frame.is_empty() { &tight } else { &last_frame });
*due += frame_interval_ns;
}
last_frame = tight;
}This duplicates frames to hit a constant declared rate rather than letting a variable capture cadence desync from wall-clock time. It’s the minimum fix, not a full VFR-aware muxer - for anything beyond a quick recording, prefer a muxing library or encoder invocation that accepts explicit per-frame PTS derived from stream_time_ns instead of a declared constant rate.
§Runnable examples
cargo run --example wayland_smoke # Linux Wayland: video + audio
cargo run --example x11_smoke # Linux X11: polling video + audio
cargo run --example audio_smoke # Linux/Windows: audio only, no dialogs
cargo run --example macos_smoke # macOS
cargo run --example windows_smoke # Windows (PINRAY_BACKEND=wgc|dxgi to force)Structs§
- Audio
Device Source - A capturable audio endpoint.
- Audio
Frame - One captured audio packet (typically ~10 ms of samples).
- Backend
Info - What a backend can do — surfaced through
CaptureSession::backend_infoso applications (and bug reports) can tell which path was actually taken. - Capture
Session - A running or configured capture session bound to the platform backend selected at build time.
- Core
Session Config - Validated session configuration; construct through the session builder.
- CvPixel
Buffer Handle - A macOS
CVPixelBufferpointer (zero-copy path, not yet produced). - D3D11
Texture Handle - A Windows
ID3D11Texture2Dpointer (zero-copy path, not yet produced). - Display
Source - A capturable display/monitor.
- Dmabuf
Frame - A Linux DMA-BUF plane handle (zero-copy path, not yet produced).
- GapEvent
- A discontinuity notice: frames were lost or the stream restarted.
- Rect
- An axis-aligned rectangle in pixels, used for crop regions and damage.
- Session
Builder - Builder for
CaptureSession; validates the configuration and resolves the platform backend onbuild. - Source
Id - Opaque identifier for a capturable source.
- Video
Frame - One captured video frame with complete layout and timing metadata.
- Window
Source - A capturable application window.
Enums§
- Audio
Capture - What to capture audio from.
- Audio
Data - Channel layout of the raw sample bytes.
- Backend
Kind - Identifies a concrete backend implementation (video or audio).
- Backend
Preference - Which backend the session builder should pick.
- Capture
Event - What
CaptureSession::next_eventyields. - Capture
Source - Anything
enumerate_sourcescan return. - Color
Space - Color space hint for interpreting pixel values.
- Cursor
Mode - Whether the pointer is drawn into captured frames.
- Frame
Data - Where a frame’s pixels live.
- GapReason
- Why a
GapEventwas emitted. - Pinray
Error - Pixel
Format - Pixel layout of
VideoFrame::data. - Sample
Format - Sample encoding of
AudioFrame::data. - Video
Capture Target - What to capture video from; ids come from source enumeration or
SourceId::new("auto")for the primary display.
Functions§
- available_
backends - Lists the capture backends compiled in for the current platform, with
their capabilities and caveats in
BackendInfo::notes. - enumerate_
sources - Enumerates capturable displays, windows, and audio sources.