shiguredo_video_device
A library that provides camera enumeration and frame capture on macOS, Linux, and Windows through a common Rust API.
Features
- Cross-platform: Handle macOS (AVFoundation), Linux (V4L2 / PipeWire), and Windows (Media Foundation) with the same API
- Backend selection via features: Choose which backends to enable and the default backend using Cargo features
- Callback-driven: During capture, the callback is invoked on the backend's thread for each frame
- Pixel formats: NV12 / YUY2 / I420, MJPEG passthrough on Linux (V4L2) (
mjpegfeature) - Minimal dependencies: The only Rust dependency crate is the
windowscrate on Windows (with themffeature) - CVPixelBuffer reference (macOS): Frames include a reference to the CVPixelBuffer (
PixelBuffer), which can be passed to VideoToolbox without copying
Version Information
- Crate name:
shiguredo_video_device - Version: 2026.4.0
- Rust Edition: 2024
- Minimum Rust version: 1.95
- Minimum macOS version: 14.0 (uses
AVCaptureDeviceTypeExternal; does not work on macOS 13 or earlier) - License: Apache-2.0
- Repository: https://github.com/shiguredo/video-device-rs
Error and PixelFormat do not have #[non_exhaustive]. All variants can be matched exhaustively.
Getting Started
[dependencies]
shiguredo_video_device = "2026.4"
By default, macOS uses AVFoundation, Linux uses V4L2, and Windows uses Media Foundation as the default backend. Features for operating systems other than the build target are ignored, so you don't need to separate dependencies per OS.
Features
| Feature | Target OS | Description |
|---|---|---|
avf | macOS | Enables the AVFoundation backend |
v4l2 | Linux | Enables the V4L2 backend |
pipewire | Linux | Enables the PipeWire backend (dynamically links to libpipewire-0.3) |
mf | Windows | Enables the Media Foundation backend (depends on the windows crate) |
mjpeg | Linux | Enables MJPEG passthrough capture on V4L2 (includes v4l2) |
default-avf | macOS | Sets the default backend to AVFoundation (includes avf) |
default-v4l2 | Linux | Sets the default backend to V4L2 (includes v4l2) |
default-pipewire | Linux | Sets the default backend to PipeWire (includes pipewire) |
default-mf | Windows | Sets the default backend to Media Foundation (includes mf) |
Default features are default-avf / default-v4l2 / default-mf.
- Enabling two or more
default-*features on the same OS will cause the build script to panic and fail to build - To set PipeWire as the default on Linux, you must use
default-features = false. This also removes the defaults for macOS/Windows, so for cross-platform applications you must re-specifydefault-avf/default-mf - On an OS without a
default-*feature,VideoDeviceList::enumerate()andVideoCapture::new()do not exist. Use explicit APIs likeenumerate_avf()ornew_v4l2() - If no backend is enabled on a given OS,
VideoCapture,VideoDevice, andVideoDeviceListare not publicly exported (only types likeErrorandPixelFormatremain) - On Linux,
v4l2andpipewirecan be enabled simultaneously - The internal
enable_*cfg flags are set by the build script for this crate only and are not accessible from the consumer side. Consumers should branch using#[cfg(target_os = "linux")]and their own features
To use PipeWire as the default backend on Linux while keeping the defaults for macOS and Windows:
[dependencies.shiguredo_video_device]
version = "2026.4"
default-features = false
features = ["default-avf", "default-mf", "default-pipewire"]
To keep V4L2 as the default but also enable PipeWire and MJPEG:
[dependencies]
shiguredo_video_device = { version = "2026.4", features = ["pipewire", "mjpeg"] }
Build Requirements
| OS | Requirements |
|---|---|
| macOS | Xcode Command Line Tools (includes libclang for bindgen) |
| Linux (V4L2) | libclang (libclang-dev on Ubuntu) |
| Linux (PipeWire) | libclang, libpipewire-0.3-dev, and pkg-config |
| Windows | No additional packages required |
Core API
Public Types
| Type | Description | Key APIs |
|---|---|---|
VideoDeviceList | Device enumeration result | enumerate(), enumerate_avf(), enumerate_v4l2(), enumerate_pipewire(), enumerate_mf(), as_slice(), len(), is_empty(), &VideoDeviceList: IntoIterator |
VideoDevice | A single device | name() -> Result<String>, unique_id() -> Result<String>, formats() -> Vec<VideoFormat>, format_count() -> usize |
VideoFormat | A format reported by the device | width: i32, height: i32, min_fps: f32, max_fps: f32, pixel_format: PixelFormat |
VideoCapture | A capture session | new(), new_avf(), new_v4l2(), new_pipewire(), new_mf(), start(), stop(), config() |
VideoCaptureConfig | Capture configuration (implements Default) | device_id: Option<String>, width: i32, height: i32, fps: i32, pixel_format: Option<PixelFormat> |
VideoFrame<'a> | A borrowed frame passed to the callback | data, uv_data, width, height, stride, stride_uv, pixel_format, timestamp_us, pixel_buffer, to_owned() |
VideoFrameOwned | An owned frame | Same fields as VideoFrame (backed by Vec<u8>), as_frame() |
PixelFormat | Pixel format | Nv12, Yuy2, I420, Mjpeg, Unknown(u32), name(), to_raw(), Display |
PixelBuffer | macOS CVPixelBuffer reference | as_ptr() -> *mut c_void (Clone performs CFRetain, Drop performs CFRelease) |
Error / Result<T> | Error type and result type | See the "Error Type" section |
enumerate() / new() use the default backend (default-*). enumerate_*() / new_*() exist only when the build target OS matches and the corresponding feature is enabled.
VideoDeviceList / VideoDevice
VideoDevicecan only be obtained as a borrowed reference (&VideoDevice) fromVideoDeviceList. If you need the information after dropping the list, copy values such asunique_id()- The value of
unique_id()can be passed directly toVideoCaptureConfig::device_id(for the same backend) name()/unique_id()returnResultbecause the C side of FFI backends (macOS / Linux) might return NULL or non-UTF-8 data. Windows never failsformat_count()is the number of entries reported by the C side, which may be larger thanformats().len()because some entries may not have been retrievedmin_fps/max_fpsinVideoFormatare approximate values that depend on the backend (see the "Backend-specific Behavior Differences" section)
VideoCapture Lifecycle
new*()only creates a session; capture does not start (on macOS, if permissions are undetermined, it may show an OS dialog and block until the user responds)start()is idempotent: returnsOk(())if already running. Re-starting afterstop()is allowedstop()blocks until the capture thread and any pending callbacks have completed. Does nothing if not running- On
drop, if the session is running, it performs a blocking stop (same asstop()) before destroying the session config()returns the requested values passed tonew*(). It is not the actual values after negotiation; for the actual resolution, format, and stride, refer to the fields of each frame
VideoCaptureConfig
| Field | Default value | Description |
|---|---|---|
device_id | None | Value of VideoDevice::unique_id(). None selects the default device |
width | 640 | Requested width |
height | 480 | Requested height |
fps | 30 | Requested frame rate |
pixel_format | None | Requested pixel format. None lets the backend decide |
- On Windows, if
width/height/fpsare less than or equal to 0, it returnsError::InvalidCaptureConfig. Other backends do not validate on the Rust side pixel_format: Some(PixelFormat::Unknown(_))returnsError::UnsupportedPixelFormaton all backendspixel_format: Some(PixelFormat::Mjpeg)is only usable on Linux V4L2 with themjpegfeature. All other cases returnError::UnsupportedPixelFormat(PixelFormat::Mjpeg)- Requested values are merely hints; the actual values are determined by the backend and device
PixelFormat
| Variant | Description | to_raw() (FourCC) |
|---|---|---|
Nv12 | YUV 4:2:0 semi-planar (Y plane + UV interleaved plane) | 0x3231564E (NV12) |
Yuy2 | YUV 4:2:2 packed (Y0 U Y1 V) | 0x32595559 (YUY2) |
I420 | YUV 4:2:0 planar (3 planes: Y / U / V) | 0x30323449 (I420) |
Mjpeg | Motion JPEG. Decoding is the user's responsibility | 0x47504A4D (MJPG) |
Unknown(u32) | Unknown FourCC | The stored value |
name()returns"NV12"/"YUY2"/"I420"/"MJPEG"/"Unknown". TheDisplayimplementation includes the value forUnknownin the formatUnknown(0x????????)- Current backends never produce
Unknown. Unknown formats are excluded fromformats(), and their frames are never passed to the callback
Frame Memory Layout
| Format | data | uv_data | stride | stride_uv |
|---|---|---|---|---|
| NV12 | Y plane | Some: UV interleaved plane (ceil(height / 2) rows) | Bytes per row for Y | Bytes per row for UV |
| I420 | Y plane | Some: U plane immediately followed by V plane (each stride_uv * ceil(height / 2) bytes) | Bytes per row for Y | Bytes per row for U and V |
| YUY2 | Packed data | None | Bytes per row | 0 |
| MJPEG | JPEG payload (length is data.len()) | None | 0 | 0 |
stridemay include padding at the end of each row. If a contiguous buffer without padding is required, repack each row (see thepack_framefunction in the code examples)- For NV12 / I420 / YUY2,
data.len()isstride * heighton FFI backends (macOS / Linux) andwidth * heighton Windows (for YUY2:width * 2 * height) timestamp_usis in microseconds, but its origin differs per backend. Use it only for relative time between frames, not as wall-clock timepixel_bufferisSomeon macOS andNoneon other platforms
Frame Callback Constraints
The callback type is F: Fn(VideoFrame<'_>) + Send + 'static.
- Slices in
VideoFrameare only valid during the callback execution. To use them after the callback, callframe.to_owned()to copy into aVideoFrameOwned - The callback is executed on the backend's capture thread. Heavy processing inside the callback may cause frame drops; use
sync_channel+try_sendto offload frames to another thread - The callback is not
FnMut, so use interior mutability (Cell,RefCell,Mutex, atomic types, etc.) for state updates. The callback is not required to beSync, meaning it will not be called concurrently - The callback must not panic. On macOS and Linux, the process may abort. On Windows, the capture thread will terminate, and a subsequent
start()afterstop()will returnError::CaptureFaulted(callingstart()withoutstop()while already running is considered running and returnsOk(())). The library does not usecatch_unwindto absorb panics - Do not call
stop()or drop theVideoCapturefrom within the callback. This can cause use-after-free on Linux (V4L2 / PipeWire) and deadlocks on macOS. On Windows,VideoCaptureis!Send, so safe Rust cannot pass it into the callback anyway. Notify another thread via a channel or flag and callstop()from that thread
Thread Safety
| Type | Send | Sync | Notes |
|---|---|---|---|
VideoDeviceList / VideoDevice | Yes | Yes | |
VideoCapture (macOS / Linux) | Yes | No | |
VideoCapture (Windows) | No | No | Holds a COM initialization guard; must be used and dropped on the same thread that created it |
VideoFrameOwned / PixelBuffer | Yes | No | Arc<VideoFrameOwned> cannot be shared across threads; to distribute to multiple receivers, clone or wrap in a Mutex |
Error | Yes | Yes | Copy |
Code Examples
Device Enumeration
use shiguredo_video_device::VideoDeviceList;
fn main() -> shiguredo_video_device::Result<()> {
// Enumerate devices using the default backend
let devices = VideoDeviceList::enumerate()?;
for device in &devices {
// name() / unique_id() may fail on FFI backends, returning a Result
println!("{} ({})", device.name()?, device.unique_id()?);
for format in device.formats() {
println!(
" {}x{} {:.0}-{:.0}fps {}",
format.width, format.height, format.min_fps, format.max_fps, format.pixel_format
);
}
}
Ok(())
}
Receiving Frames via Channel (Recommended Pattern)
use std::sync::mpsc::sync_channel;
use std::time::Duration;
use shiguredo_video_device::{VideoCapture, VideoCaptureConfig, VideoFrame, VideoFrameOwned};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Use a bounded channel for backpressure. An unbounded channel will
// continuously grow memory if the receiver is slow
let (tx, rx) = sync_channel::<VideoFrameOwned>(4);
let config = VideoCaptureConfig {
width: 1280,
height: 720,
fps: 30,
..VideoCaptureConfig::default()
};
let mut capture = VideoCapture::new(config, move |frame: VideoFrame<'_>| {
// Slices are only valid during the callback, so copy via to_owned() before sending
// If the receiver is blocked, discard the frame without blocking
let _ = tx.try_send(frame.to_owned());
})?;
capture.start()?;
for _ in 0..30 {
let frame = rx.recv_timeout(Duration::from_secs(5))?;
// Check the actual resolution and format from the frame (config() only returns requested values)
println!(
"{}x{} {} ts={}us",
frame.width, frame.height, frame.pixel_format, frame.timestamp_us
);
}
// stop() blocks until callbacks complete. Do not call from within the callback
capture.stop();
Ok(())
}
Specifying a Device and Format
use shiguredo_video_device::{
Error, PixelFormat, VideoCapture, VideoCaptureConfig, VideoDeviceList,
};
fn open_first_device() -> Result<VideoCapture, Box<dyn std::error::Error>> {
let devices = VideoDeviceList::enumerate()?;
let Some(device) = devices.as_slice().first() else {
return Err("no video device found".into());
};
// If the device reports NV12 at 1280x720, request it; otherwise, let the backend decide
let pixel_format = device
.formats()
.into_iter()
.find(|f| f.width == 1280 && f.height == 720 && f.pixel_format == PixelFormat::Nv12)
.map(|f| f.pixel_format);
let config = VideoCaptureConfig {
// The value of unique_id() can be passed directly as device_id
device_id: Some(device.unique_id()?),
width: 1280,
height: 720,
fps: 30,
pixel_format,
};
match VideoCapture::new(config, |frame| {
let _ = frame.data.len();
}) {
Ok(capture) => Ok(capture),
Err(Error::UnsupportedPixelFormat(format)) => {
Err(format!("pixel format {format} is not supported").into())
}
Err(e) => Err(e.into()),
}
}
Removing Stride to Create a Contiguous Buffer
Use this when an encoder or similar component requires a contiguous buffer without padding at the end of each row.
use shiguredo_video_device::{PixelFormat, VideoFrame};
/// Concatenates only the valid bytes of each row from a plane with stride
fn pack_plane(src: &[u8], stride: usize, row_bytes: usize, rows: usize) -> Option<Vec<u8>> {
if row_bytes > stride {
return None;
}
// Dimensions come from the device and may not be trustworthy; grow dynamically from actual data
let mut out = Vec::new();
for row in 0..rows {
let start = row.checked_mul(stride)?;
out.extend_from_slice(src.get(start..start.checked_add(row_bytes)?)?);
}
Some(out)
}
/// Packs a frame into a contiguous buffer without stride. Returns None if dimensions are inconsistent
fn pack_frame(frame: &VideoFrame<'_>) -> Option<Vec<u8>> {
let w = usize::try_from(frame.width).ok()?;
let h = usize::try_from(frame.height).ok()?;
let stride = usize::try_from(frame.stride).ok()?;
let stride_uv = usize::try_from(frame.stride_uv).ok()?;
let chroma_w = w.div_ceil(2);
let chroma_h = h.div_ceil(2);
match frame.pixel_format {
PixelFormat::Nv12 => {
let mut out = pack_plane(frame.data, stride, w, h)?;
// UV is interleaved (U and V alternate), so valid bytes per row = chroma_w * 2
out.extend(pack_plane(
frame.uv_data?,
stride_uv,
chroma_w * 2,
chroma_h,
)?);
Some(out)
}
PixelFormat::I420 => {
let uv = frame.uv_data?;
// uv_data is U plane immediately followed by V plane
let plane_size = stride_uv.checked_mul(chroma_h)?;
let mut out = pack_plane(frame.data, stride, w, h)?;
out.extend(pack_plane(
uv.get(..plane_size)?,
stride_uv,
chroma_w,
chroma_h,
)?);
out.extend(pack_plane(
uv.get(plane_size..)?,
stride_uv,
chroma_w,
chroma_h,
)?);
Some(out)
}
// YUY2 uses 4 bytes per 2 pixels
PixelFormat::Yuy2 => pack_plane(frame.data, stride, chroma_w * 4, h),
// MJPEG has no stride; it's a JPEG payload
PixelFormat::Mjpeg => Some(frame.data.to_vec()),
PixelFormat::Unknown(_) => None,
}
}
Using a Non-Default Backend Explicitly (Linux PipeWire)
// Assumes the pipewire feature is enabled in dependencies
// Apply cfg to use as well to avoid unused import warnings on other OSes
#[cfg(target_os = "linux")]
use shiguredo_video_device::{
VideoCapture, VideoCaptureConfig, VideoDeviceList,
};
#[cfg(target_os = "linux")]
fn enumerate_pipewire() -> shiguredo_video_device::Result<()> {
let devices = VideoDeviceList::enumerate_pipewire()?;
for device in &devices {
println!("{} ({})", device.name()?, device.unique_id()?);
}
Ok(())
}
fn main() {
#[cfg(target_os = "linux")]
{
if let Err(e) = enumerate_pipewire() {
eprintln!("Error: {e}");
}
}
#[cfg(not(target_os = "linux"))]
{
println!("PipeWire is only available on Linux");
}
}
Error Type
Error implements std::error::Error, Display, Debug, Clone, PartialEq, and Copy. All variants are exhaustive (no #[non_exhaustive]).
| Variant | Description |
|---|---|
DeviceNotFound | No compatible video device found. On macOS, this may indicate insufficient permissions |
FailedToCreateSession | The backend failed to create a capture session |
FailedToStartCapture | Capture failed to start |
CaptureFaulted | The capture thread terminated unexpectedly due to a panic in the callback (Windows only) |
UnsupportedPixelFormat(PixelFormat) | The requested pixel format is not supported |
InvalidCaptureConfig | The configuration contains invalid values (Windows only) |
BackendNotSupported | The backend is not supported on the current OS or the feature is not enabled |
Io(std::io::Error) | Wraps std::io::Error (Linux V4L2 only) |
BackendNotSupportedoccurs when the feature for the current OS is not enabled or the OS is not supported, and is returned at runtime (not compile time)CaptureFaultedis specific to Windows; it occurs when the capture thread terminates due to a callback panic. After returning this error, the capture thread stops. Callingstart()afterstop()will attempt to restart
Backend-specific Behavior Differences
macOS (AVFoundation)
- Minimum version is macOS 14.0 (uses
AVCaptureDeviceTypeExternal; does not work on macOS 13 or earlier) min_fps/max_fpsinVideoFormat:min_fpsis always 0,max_fpsis the maximum value extracted fromAVFrameRateRange- Permissions: if the application has not been granted camera permission,
new*()displays the OS dialog and blocks until the user responds. If denied, all backends returnError::DeviceNotFoundduringnew()orstart(). Denied permissions persist until the user changes them in System Settings - When using AVCaptureDeviceTypeExternal, minimized applications may produce black frames. You need to detect this on the application side. The library does not modify the device's automatic behavior
Linux (V4L2)
min_fps/max_fpsinVideoFormat: If the device driver does not report these values,min_fpsis set tof32::MAXandmax_fpsis set tof32::MIN- The
width,height, andfpsreported byformats()may include combinations that are not actually supported. An error may occur duringstart() - The
mjpegfeature enables MJPEG passthrough capture. Decoding the JPEG payload is the user's responsibility
Linux (PipeWire)
min_fps/max_fpsinVideoFormatalways reflect nominal values because metadata is not obtainable via the current API- Requires dynamic linking to
libpipewire-0.3; the library must be installed on the runtime system
Windows (Media Foundation)
min_fps/max_fpsinVideoFormat:min_fpsis always 0,max_fpsis the value ofMF_MT_FRAME_RATE_RANGE_MAXif it exists, otherwise 0- The number of reported formats (
format_count()) may be larger than the number of actually obtainable formats (formats().len()). This is because the IMFMediaTypeHandler returns a count that does not match the actual enumeration width,height, andfpsare validated. If the requested values are 0 or less,Error::InvalidCaptureConfigis returned. If the requested format does not exist,start()returnsError::FailedToStartCapture- COM initialization: the library uses
RO_INIT_MULTITHREADEDfor initialization. If the application has already calledCoInitializeExwithCOINIT_APARTMENTTHREADED, COM initialization will fail. No additional COM interop is required on the application side (VideoCaptureholds a COM guard) - If the callback panics, the capture thread terminates. Subsequent
start()afterstop()returnsError::CaptureFaulted