Communitygithub.com

shiguredo/shiguredo-video-device

Cross-platform camera capture library from Shiguredo, providing Rust API for device enumeration and frame capture on macOS (AVFoundation), Linux (V4L2/PipeWire), and Windows (Media Foundation).

What is shiguredo-video-device?

This AI agent skill provides comprehensive knowledge of the `shiguredo_video_device` Rust crate, a cross-platform library for enumerating cameras and capturing video frames on macOS (AVFoundation), Linux (V4L2 and PipeWire), and Windows (Media Foundation). It allows developers to query the library's core API, including `VideoDeviceList`, `VideoDevice`, `VideoFormat`, `VideoCapture`, `VideoCaptureConfig`, and `VideoFrame`. The skill covers backend selection via Cargo features, pixel format memory layouts (NV12, YUY2, I420, MJPEG), frame callback constraints and thread safety, and important backend-specific behavior differences such as the macOS minimum version requirement and Windows COM initialization requirements. It also includes code examples for device enumeration, frame capture via channels, and stride removal, making it useful for developers integrating camera capture into Rust applications targeting multiple platforms. The skill is designed for use with AI agents that assist with Rust development, build systems, and cross-platform multimedia pipelines.

Works with~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/shiguredo/video-device-rs/tree/HEAD/skills/shiguredo-video-device

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

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) (mjpeg feature)
  • Minimal dependencies: The only Rust dependency crate is the windows crate on Windows (with the mf feature)
  • 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

FeatureTarget OSDescription
avfmacOSEnables the AVFoundation backend
v4l2LinuxEnables the V4L2 backend
pipewireLinuxEnables the PipeWire backend (dynamically links to libpipewire-0.3)
mfWindowsEnables the Media Foundation backend (depends on the windows crate)
mjpegLinuxEnables MJPEG passthrough capture on V4L2 (includes v4l2)
default-avfmacOSSets the default backend to AVFoundation (includes avf)
default-v4l2LinuxSets the default backend to V4L2 (includes v4l2)
default-pipewireLinuxSets the default backend to PipeWire (includes pipewire)
default-mfWindowsSets 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-specify default-avf / default-mf
  • On an OS without a default-* feature, VideoDeviceList::enumerate() and VideoCapture::new() do not exist. Use explicit APIs like enumerate_avf() or new_v4l2()
  • If no backend is enabled on a given OS, VideoCapture, VideoDevice, and VideoDeviceList are not publicly exported (only types like Error and PixelFormat remain)
  • On Linux, v4l2 and pipewire can 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

OSRequirements
macOSXcode Command Line Tools (includes libclang for bindgen)
Linux (V4L2)libclang (libclang-dev on Ubuntu)
Linux (PipeWire)libclang, libpipewire-0.3-dev, and pkg-config
WindowsNo additional packages required

Core API

Public Types

TypeDescriptionKey APIs
VideoDeviceListDevice enumeration resultenumerate(), enumerate_avf(), enumerate_v4l2(), enumerate_pipewire(), enumerate_mf(), as_slice(), len(), is_empty(), &VideoDeviceList: IntoIterator
VideoDeviceA single devicename() -> Result<String>, unique_id() -> Result<String>, formats() -> Vec<VideoFormat>, format_count() -> usize
VideoFormatA format reported by the devicewidth: i32, height: i32, min_fps: f32, max_fps: f32, pixel_format: PixelFormat
VideoCaptureA capture sessionnew(), new_avf(), new_v4l2(), new_pipewire(), new_mf(), start(), stop(), config()
VideoCaptureConfigCapture 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 callbackdata, uv_data, width, height, stride, stride_uv, pixel_format, timestamp_us, pixel_buffer, to_owned()
VideoFrameOwnedAn owned frameSame fields as VideoFrame (backed by Vec<u8>), as_frame()
PixelFormatPixel formatNv12, Yuy2, I420, Mjpeg, Unknown(u32), name(), to_raw(), Display
PixelBuffermacOS CVPixelBuffer referenceas_ptr() -> *mut c_void (Clone performs CFRetain, Drop performs CFRelease)
Error / Result<T>Error type and result typeSee 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

  • VideoDevice can only be obtained as a borrowed reference (&VideoDevice) from VideoDeviceList. If you need the information after dropping the list, copy values such as unique_id()
  • The value of unique_id() can be passed directly to VideoCaptureConfig::device_id (for the same backend)
  • name() / unique_id() return Result because the C side of FFI backends (macOS / Linux) might return NULL or non-UTF-8 data. Windows never fails
  • format_count() is the number of entries reported by the C side, which may be larger than formats().len() because some entries may not have been retrieved
  • min_fps / max_fps in VideoFormat are 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: returns Ok(()) if already running. Re-starting after stop() is allowed
  • stop() 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 as stop()) before destroying the session
  • config() returns the requested values passed to new*(). It is not the actual values after negotiation; for the actual resolution, format, and stride, refer to the fields of each frame

VideoCaptureConfig

FieldDefault valueDescription
device_idNoneValue of VideoDevice::unique_id(). None selects the default device
width640Requested width
height480Requested height
fps30Requested frame rate
pixel_formatNoneRequested pixel format. None lets the backend decide
  • On Windows, if width / height / fps are less than or equal to 0, it returns Error::InvalidCaptureConfig. Other backends do not validate on the Rust side
  • pixel_format: Some(PixelFormat::Unknown(_)) returns Error::UnsupportedPixelFormat on all backends
  • pixel_format: Some(PixelFormat::Mjpeg) is only usable on Linux V4L2 with the mjpeg feature. All other cases return Error::UnsupportedPixelFormat(PixelFormat::Mjpeg)
  • Requested values are merely hints; the actual values are determined by the backend and device

PixelFormat

VariantDescriptionto_raw() (FourCC)
Nv12YUV 4:2:0 semi-planar (Y plane + UV interleaved plane)0x3231564E (NV12)
Yuy2YUV 4:2:2 packed (Y0 U Y1 V)0x32595559 (YUY2)
I420YUV 4:2:0 planar (3 planes: Y / U / V)0x30323449 (I420)
MjpegMotion JPEG. Decoding is the user's responsibility0x47504A4D (MJPG)
Unknown(u32)Unknown FourCCThe stored value
  • name() returns "NV12" / "YUY2" / "I420" / "MJPEG" / "Unknown". The Display implementation includes the value for Unknown in the format Unknown(0x????????)
  • Current backends never produce Unknown. Unknown formats are excluded from formats(), and their frames are never passed to the callback

Frame Memory Layout

Formatdatauv_datastridestride_uv
NV12Y planeSome: UV interleaved plane (ceil(height / 2) rows)Bytes per row for YBytes per row for UV
I420Y planeSome: U plane immediately followed by V plane (each stride_uv * ceil(height / 2) bytes)Bytes per row for YBytes per row for U and V
YUY2Packed dataNoneBytes per row0
MJPEGJPEG payload (length is data.len())None00
  • stride may include padding at the end of each row. If a contiguous buffer without padding is required, repack each row (see the pack_frame function in the code examples)
  • For NV12 / I420 / YUY2, data.len() is stride * height on FFI backends (macOS / Linux) and width * height on Windows (for YUY2: width * 2 * height)
  • timestamp_us is in microseconds, but its origin differs per backend. Use it only for relative time between frames, not as wall-clock time
  • pixel_buffer is Some on macOS and None on other platforms

Frame Callback Constraints

The callback type is F: Fn(VideoFrame<'_>) + Send + 'static.

  • Slices in VideoFrame are only valid during the callback execution. To use them after the callback, call frame.to_owned() to copy into a VideoFrameOwned
  • The callback is executed on the backend's capture thread. Heavy processing inside the callback may cause frame drops; use sync_channel + try_send to 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 be Sync, 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() after stop() will return Error::CaptureFaulted (calling start() without stop() while already running is considered running and returns Ok(())). The library does not use catch_unwind to absorb panics
  • Do not call stop() or drop the VideoCapture from within the callback. This can cause use-after-free on Linux (V4L2 / PipeWire) and deadlocks on macOS. On Windows, VideoCapture is !Send, so safe Rust cannot pass it into the callback anyway. Notify another thread via a channel or flag and call stop() from that thread

Thread Safety

TypeSendSyncNotes
VideoDeviceList / VideoDeviceYesYes
VideoCapture (macOS / Linux)YesNo
VideoCapture (Windows)NoNoHolds a COM initialization guard; must be used and dropped on the same thread that created it
VideoFrameOwned / PixelBufferYesNoArc<VideoFrameOwned> cannot be shared across threads; to distribute to multiple receivers, clone or wrap in a Mutex
ErrorYesYesCopy

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]).

VariantDescription
DeviceNotFoundNo compatible video device found. On macOS, this may indicate insufficient permissions
FailedToCreateSessionThe backend failed to create a capture session
FailedToStartCaptureCapture failed to start
CaptureFaultedThe capture thread terminated unexpectedly due to a panic in the callback (Windows only)
UnsupportedPixelFormat(PixelFormat)The requested pixel format is not supported
InvalidCaptureConfigThe configuration contains invalid values (Windows only)
BackendNotSupportedThe 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)
  • BackendNotSupported occurs when the feature for the current OS is not enabled or the OS is not supported, and is returned at runtime (not compile time)
  • CaptureFaulted is specific to Windows; it occurs when the capture thread terminates due to a callback panic. After returning this error, the capture thread stops. Calling start() after stop() 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_fps in VideoFormat: min_fps is always 0, max_fps is the maximum value extracted from AVFrameRateRange
  • 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 return Error::DeviceNotFound during new() or start(). 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_fps in VideoFormat: If the device driver does not report these values, min_fps is set to f32::MAX and max_fps is set to f32::MIN
  • The width, height, and fps reported by formats() may include combinations that are not actually supported. An error may occur during start()
  • The mjpeg feature enables MJPEG passthrough capture. Decoding the JPEG payload is the user's responsibility

Linux (PipeWire)

  • min_fps / max_fps in VideoFormat always 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_fps in VideoFormat: min_fps is always 0, max_fps is the value of MF_MT_FRAME_RATE_RANGE_MAX if 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, and fps are validated. If the requested values are 0 or less, Error::InvalidCaptureConfig is returned. If the requested format does not exist, start() returns Error::FailedToStartCapture
  • COM initialization: the library uses RO_INIT_MULTITHREADED for initialization. If the application has already called CoInitializeEx with COINIT_APARTMENTTHREADED, COM initialization will fail. No additional COM interop is required on the application side (VideoCapture holds a COM guard)
  • If the callback panics, the capture thread terminates. Subsequent start() after stop() returns Error::CaptureFaulted

Related Skills