shiguredo_video_device
Librería que proporciona enumeración de cámaras y captura de fotogramas en macOS / Linux / Windows mediante una API Rust común para todas las plataformas.
Características
- Multiplataforma: macOS (AVFoundation), Linux (V4L2 / PipeWire) y Windows (Media Foundation) con la misma API
- Selección de backend mediante features: El backend habilitado y el predeterminado se eligen con features de Cargo
- Orientado a callbacks: Durante la captura, se invoca un callback por cada fotograma en el hilo del backend
- Formatos de píxel: NV12 / YUY2 / I420; en Linux (V4L2) también paso directo de MJPEG (feature
mjpeg) - Pocas dependencias: La única crate de Rust dependiente es
windows(featuremf) en Windows - Referencia CVPixelBuffer (macOS): El fotograma incluye una referencia (
PixelBuffer) al CVPixelBuffer, que se puede pasar a VideoToolbox sin copiar
Información de versión
- Nombre de la crate:
shiguredo_video_device - Versión: 2026.4.0
- Rust Edition: 2024
- Versión mínima de Rust: 1.95
- Versión mínima de macOS: 14.0 (porque referencia
AVCaptureDeviceTypeExternal; no funciona en macOS 13 o anterior) - Licencia: Apache-2.0
- Repositorio: https://github.com/shiguredo/video-device-rs
Error y PixelFormat no tienen #[non_exhaustive]. Se pueden enumerar todas las variantes con match.
Instalación
[dependencies]
shiguredo_video_device = "2026.4"
Por defecto, el backend predeterminado es AVFoundation en macOS, V4L2 en Linux y Media Foundation en Windows. Las features para un SO diferente al de compilación se ignoran, por lo que no es necesario separar dependencias por SO.
Features
| Feature | SO objetivo | Descripción |
|---|---|---|
avf | macOS | Habilita el backend AVFoundation |
v4l2 | Linux | Habilita el backend V4L2 |
pipewire | Linux | Habilita el backend PipeWire (enlaza dinámicamente con libpipewire-0.3) |
mf | Windows | Habilita el backend Media Foundation (depende de la crate windows) |
mjpeg | Linux | Habilita la captura paso directo de MJPEG en V4L2 (incluye v4l2) |
default-avf | macOS | Establece AVFoundation como backend predeterminado (incluye avf) |
default-v4l2 | Linux | Establece V4L2 como backend predeterminado (incluye v4l2) |
default-pipewire | Linux | Establece PipeWire como backend predeterminado (incluye pipewire) |
default-mf | Windows | Establece Media Foundation como backend predeterminado (incluye mf) |
Las features por defecto son default-avf / default-v4l2 / default-mf.
- Si se habilitan dos o más
default-*en el mismo SO, el script de compilación entrará en pánico y no se podrá compilar. - Para usar PipeWire como predeterminado en Linux, se necesita
default-features = false. Esto también desactiva los predeterminados de macOS y Windows, por lo que en aplicaciones multiplataforma hay que volver a especificardefault-avf/default-mf. - En un SO sin
default-*,VideoDeviceList::enumerate()yVideoCapture::new()no existen. Se deben usar las API explícitas comoenumerate_avf()onew_v4l2(). - Si no hay ningún backend habilitado para un SO, no se exportan
VideoCapture,VideoDeviceniVideoDeviceList(solo quedan los tiposError,PixelFormat, etc.). - En Linux se pueden habilitar
v4l2ypipewiresimultáneamente. - Los cfg
enable_*internos de la crate los establece el script de compilación solo para esta crate y no son accesibles desde fuera. El código usuario debe usar#[cfg(target_os = "linux")]y sus propias features para bifurcar.
Para usar PipeWire como predeterminado en Linux y mantener los predeterminados en macOS y Windows:
[dependencies.shiguredo_video_device]
version = "2026.4"
default-features = false
features = ["default-avf", "default-mf", "default-pipewire"]
Para mantener V4L2 como predeterminado pero también habilitar PipeWire y MJPEG:
[dependencies]
shiguredo_video_device = { version = "2026.4", features = ["pipewire", "mjpeg"] }
Requisitos de compilación
| SO | Requisitos |
|---|---|
| macOS | Xcode Command Line Tools (incluye libclang para bindgen) |
| Linux (V4L2) | libclang (en Ubuntu: libclang-dev) |
| Linux (PipeWire) | libclang, libpipewire-0.3-dev y pkg-config |
| Windows | Sin dependencias adicionales |
API principal
Tipos públicos
| Tipo | Descripción | API principal |
|---|---|---|
VideoDeviceList | Resultado de enumerar dispositivos | enumerate(), enumerate_avf(), enumerate_v4l2(), enumerate_pipewire(), enumerate_mf(), as_slice(), len(), is_empty(), &VideoDeviceList: IntoIterator |
VideoDevice | Un dispositivo | name() -> Result<String>, unique_id() -> Result<String>, formats() -> Vec<VideoFormat>, format_count() -> usize |
VideoFormat | Formato reportado por el dispositivo | width: i32, height: i32, min_fps: f32, max_fps: f32, pixel_format: PixelFormat |
VideoCapture | Sesión de captura | new(), new_avf(), new_v4l2(), new_pipewire(), new_mf(), start(), stop(), config() |
VideoCaptureConfig | Configuración de captura (implementa Default) | device_id: Option<String>, width: i32, height: i32, fps: i32, pixel_format: Option<PixelFormat> |
VideoFrame<'a> | Fotograma prestado que se pasa al callback | data, uv_data, width, height, stride, stride_uv, pixel_format, timestamp_us, pixel_buffer, to_owned() |
VideoFrameOwned | Fotograma propio | Mismos campos que VideoFrame (almacenados en Vec<u8>), as_frame() |
PixelFormat | Formato de píxel | Nv12, Yuy2, I420, Mjpeg, Unknown(u32), name(), to_raw(), Display |
PixelBuffer | Referencia a CVPixelBuffer en macOS | as_ptr() -> *mut c_void (Clone hace CFRetain, Drop hace CFRelease) |
Error / Result<T> | Tipo de error y tipo resultado | Ver la sección "Tipo Error" |
enumerate() y new() usan el backend predeterminado (default-*). enumerate_*() y new_*() solo existen si el SO objetivo coincide y la feature correspondiente está habilitada.
VideoDeviceList / VideoDevice
VideoDevicesolo se puede obtener como referencia prestada (&VideoDevice) desdeVideoDeviceList. Si se necesita la información después de descartar la lista, hay que copiar el valor deunique_id()u otros campos.- El valor de
unique_id()se puede pasar directamente aVideoCaptureConfig::device_id(limitado al mismo backend). name()yunique_id()devuelvenResultporque en los backends FFI (macOS / Linux) el lado C puede devolver NULL o cadenas no UTF-8. En Windows no fallan.format_count()es el número de entradas reportado por el lado C, que puede ser mayor queformats().len()porque algunas entradas no se pudieron obtener.min_fpsymax_fpsdeVideoFormatson valores aproximados que dependen del backend (ver la sección "Diferencias de comportamiento entre backends").
Ciclo de vida de VideoCapture
new*()solo crea la sesión, no inicia la captura (en macOS, si los permisos no están determinados, muestra el diálogo del sistema y no retorna hasta que el usuario responda).start()es idempotente: si ya está en ejecución, devuelveOk(()). Se permite volver a llamarstart()después destop().stop()es bloqueante: espera a que el hilo de captura y el callback terminen antes de retornar. Si no está en ejecución, no hace nada.- Al hacer
drop, si está en ejecución, realiza una parada bloqueante comostop()y luego destruye la sesión. config()devuelve los valores solicitados ennew*(). No son los valores reales negociados; para resolución, formato y stride reales, consultar los campos de cada fotograma.
VideoCaptureConfig
| Campo | Valor por defecto | Descripción |
|---|---|---|
device_id | None | Valor de VideoDevice::unique_id(). None usa el dispositivo predeterminado |
width | 640 | Ancho solicitado |
height | 480 | Alto solicitado |
fps | 30 | Fotogramas por segundo solicitados |
pixel_format | None | Formato de píxel solicitado. None deja que el backend elija |
- En Windows, si
width,heightofpsson <= 0, se devuelveError::InvalidCaptureConfig. Otros backends no validan desde Rust. pixel_format: Some(PixelFormat::Unknown(_))devuelveError::UnsupportedPixelFormaten todos los backends.pixel_format: Some(PixelFormat::Mjpeg)solo funciona en Linux V4L2 con la featuremjpeg. En cualquier otro caso devuelveError::UnsupportedPixelFormat(PixelFormat::Mjpeg).- Los valores solicitados son solo una preferencia; los valores reales los determinan el backend y el dispositivo.
PixelFormat
| Variante | Contenido | to_raw() (FourCC) |
|---|---|---|
Nv12 | YUV 4:2:0 semiplano (plano Y + plano UV entrelazado) | 0x3231564E (NV12) |
Yuy2 | YUV 4:2:2 empaquetado (Y0 U Y1 V) | 0x32595559 (YUY2) |
I420 | YUV 4:2:0 planar (3 planos: Y / U / V) | 0x30323449 (I420) |
Mjpeg | Motion JPEG. La decodificación es responsabilidad del usuario | 0x47504A4D (MJPG) |
Unknown(u32) | FourCC desconocido | El valor almacenado |
name()devuelve"NV12","YUY2","I420","MJPEG"o"Unknown".DisplayparaUnknownincluye el valor en formatoUnknown(0x????????).- Actualmente los backends no generan
Unknown. Los formatos desconocidos se excluyen deformats()y sus fotogramas no se pasan al callback.
Diseño de memoria de los fotogramas
| Formato | data | uv_data | stride | stride_uv |
|---|---|---|---|---|
| NV12 | Plano Y | Some: plano UV entrelazado (ceil(height / 2) filas) | Bytes por fila del plano Y | Bytes por fila del plano UV |
| I420 | Plano Y | Some: regiones contiguas: plano U seguido de plano V (cada uno de stride_uv * ceil(height / 2) bytes) | Bytes por fila del plano Y | Bytes por fila de cada plano U / V |
| YUY2 | Datos empaquetados | None | Bytes por fila | 0 |
| MJPEG | Carga útil JPEG (longitud en data.len()) | None | 0 | 0 |
stridepuede incluir relleno al final de la fila. Si se necesita un búfer contiguo sin stride, hay que reempaquetar fila por fila (verpack_frameen "Ejemplos de código").data.len()para NV12 / I420 / YUY2 esstride * heighten backends FFI (macOS / Linux) ywidth * height(YUY2:width * 2 * height) en Windows.timestamp_usestá en microsegundos, pero el origen varía según el backend. Solo se debe usar para tiempos relativos entre fotogramas, no como hora de reloj de pared.pixel_bufferesSomeen macOS yNoneen el resto.
Restricciones del callback de fotogramas
El tipo del callback es F: Fn(VideoFrame<'_>) + Send + 'static.
- Los slices de
VideoFramesolo son válidos durante la ejecución del callback. Para usarlos después, copiar conframe.to_owned()aVideoFrameOwned. - El callback se ejecuta en el hilo de captura del backend. Las operaciones pesadas pueden provocar pérdida de fotogramas; se recomienda usar
sync_channel+try_sendpara pasar los datos a otro hilo. - Como no es
FnMut, para actualizar el estado se necesita mutabilidad interior (Cell,RefCell,Mutex, atómicos, etc.). No requiereSync, por lo que el callback nunca se invoca concurrentemente. - El callback no debe entrar en pánico. En macOS y Linux puede abortar el proceso. En Windows, el hilo de captura finaliza y un
start()posterior astop()devuelveError::CaptureFaulted(sinstop()intermedio,start()lo considera en ejecución y devuelveOk(())). La librería no captura el pánico concatch_unwind. - No se debe llamar a
stop()ni hacer drop deVideoCapturedesde el callback. En Linux (V4L2 / PipeWire) puede causar use-after-free, y en macOS, un interbloqueo. En Windows,VideoCaptureno esSend, por lo que safe Rust impide pasarlo al callback. Para detener, notificar a otro hilo mediante un canal o bandera, y que ese hilo llame astop().
Seguridad en hilos
| Tipo | Send | Sync | Notas |
|---|---|---|---|
VideoDeviceList / VideoDevice | Sí | Sí | |
VideoCapture (macOS / Linux) | Sí | No | |
VideoCapture (Windows) | No | No | Porque contiene un guardia de inicialización COM; debe usarse y dropearse en el mismo hilo que lo creó |
VideoFrameOwned / PixelBuffer | Sí | No | Arc<VideoFrameOwned> no se puede compartir entre hilos. Para distribuir a varios receptores, clonar o envolver en Mutex |
Error | Sí | Sí | Copy |
Ejemplos de código
Enumeración de dispositivos
use shiguredo_video_device::VideoDeviceList;
fn main() -> shiguredo_video_device::Result<()> {
// Enumerar dispositivos con el backend predeterminado
let devices = VideoDeviceList::enumerate()?;
for device in &devices {
// name() / unique_id() pueden fallar en backends FFI, por eso devuelven 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(())
}
Recibir fotogramas a través de un canal (patrón recomendado)
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>> {
// Canal con límite para aplicar contrapresión. Un canal sin límite haría crecer la memoria si el receptor va lento
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<'_>| {
// Los slices solo son válidos durante el callback, así que copiamos con to_owned() antes de enviar
// Si el receptor está saturado, descartamos el fotograma sin bloquear
let _ = tx.try_send(frame.to_owned());
})?;
capture.start()?;
for _ in 0..30 {
let frame = rx.recv_timeout(Duration::from_secs(5))?;
// La resolución y formato reales están en el fotograma (config() solo devuelve los valores solicitados)
println!(
"{}x{} {} ts={}us",
frame.width, frame.height, frame.pixel_format, frame.timestamp_us
);
}
// stop() espera a que los callbacks terminen antes de retornar. No se debe llamar desde el callback
capture.stop();
Ok(())
}
Especificar dispositivo y formato
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 se encontró ningún dispositivo de video".into());
};
// Si el dispositivo reporta NV12 a 1280x720, lo solicitamos; si no, dejamos que el backend decida
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 {
// El valor de unique_id() se puede pasar directamente a 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!("el formato de píxel {format} no es compatible").into())
}
Err(e) => Err(e.into()),
}
}
Eliminar el stride para obtener un búfer contiguo
Útil cuando un codificador u otro componente requiere un búfer contiguo sin relleno.
use shiguredo_video_device::{PixelFormat, VideoFrame};
/// Concatena solo los bytes válidos de cada fila de un plano con stride
fn pack_plane(src: &[u8], stride: usize, row_bytes: usize, rows: usize) -> Option<Vec<u8>> {
if row_bytes > stride {
return None;
}
// Las dimensiones vienen del dispositivo y no son fiables, por lo que no preasignamos
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)
}
/// Empaqueta el fotograma en un búfer contiguo sin stride. Devuelve None si las dimensiones son inconsistentes
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: U y V se alternan, por lo que los bytes válidos por fila son 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 contiene el plano U seguido inmediatamente del plano V
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: 4 bytes por cada 2 píxeles
PixelFormat::Yuy2 => pack_plane(frame.data, stride, chroma_w * 4, h),
// MJPEG: carga útil JPEG sin stride
PixelFormat::Mjpeg => Some(frame.data.to_vec()),
PixelFormat::Unknown(_) => None,
}
}
Especificar un backend distinto al predeterminado (Linux PipeWire)
// Se asume que la feature pipewire está habilitada en las dependencias
// Para evitar importaciones no usadas en otros SO, se añade cfg al use
#[cfg(target_os = "linux")]
use shiguredo_video_device::VideoCapture;
#[cfg(target_os = "linux")]
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut capture = VideoCapture::new_pipewire(
VideoCaptureConfig::default(),
|frame| println!("fotograma recibido: {}x{}", frame.width, frame.height),
)?;
capture.start()?;
std::thread::sleep(std::time::Duration::from_secs(2));
capture.stop();
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn main() {
println!("Este ejemplo solo funciona en Linux");
}
Tipo Error
pub enum Error {
/// El backend no pudo enumerar dispositivos
Enumerate(&'static str),
/// No se pudo abrir el dispositivo
OpenDevice(&'static str),
/// No se pudo iniciar la captura
StartCapture(&'static str),
/// No se pudo detener la captura
StopCapture(&'static str),
/// No se pudo crear la sesión
CreateSession(&'static str),
/// Fallo en la configuración de captura
InvalidCaptureConfig(&'static str),
/// Formato de píxel no soportado
UnsupportedPixelFormat(PixelFormat),
/// La captura falló; se requiere stop() antes de volver a start()
CaptureFaulted,
}
pub type Result<T> = std::result::Result<T, Error>;
Error implementa std::error::Error, Display y Copy. Los mensajes de error de los backends son literales de cadena estáticos (&'static str).
Diferencias de comportamiento entre backends
macOS (AVFoundation)
- Permisos:
new*()solicita permiso de cámara conAVCaptureDevice.requestAccess(for: .video). Si el usuario deniega, el callback nunca se invoca ystart()devuelveOk(()). No hay forma de detectar la denegación desde esta librería; el desarrollador debe comprobarAVCaptureDevice.authorizationStatus(for: .video)antes de crear la sesión. - CVPixelBuffer: Los fotogramas contienen
pixel_buffer: Some(PixelBuffer(...)). Se puede pasar a VideoToolbox (por ejemplo,VTCompressionSessionEncodeFrame) sin copiar. - Reinicio: Después de
stop(), se puede volver astart()sin problema. - Dispositivo predeterminado:
device_id: Noneusa la cámara integrada (AVCaptureDevice.default(.external, ...)). Si no hay ninguna cámara externa, usa la integrada (AVCaptureDevice.default(.builtInWideAngleCamera, ...)).
Linux (V4L2)
mjpegfeature: Si está habilitada y el dispositivo lo soporta, se puede capturar en MJPEG. La carga útil del fotograma son datos JPEG sin procesar.- Resolución: V4L2 puede redondear la resolución a la más cercana soportada. La resolución real se obtiene del fotograma.
- Dispositivo predeterminado:
device_id: Noneusa/dev/video0. - Eliminación de formato: Los formatos que fallan al consultar (
VIDIOC_ENUM_FMT) conEIOse omiten.
Linux (PipeWire)
- Requisitos del sistema: Se necesita un servicio PipeWire en ejecución.
- Enumeración: Usa la API de PipeWire para listar dispositivos.
- Dispositivo predeterminado:
device_id: Noneusa el primer dispositivo encontrado (orden de PipeWire).
Windows (Media Foundation)
- Validación de parámetros:
width,heightyfpsdeben ser > 0, de lo contrarioError::InvalidCaptureConfig. - COM: El hilo que crea
VideoCapturedebe haber inicializado COM conCoInitializeEx. El tipoVideoCapturecontiene un guardia que asegura COM en el mismo hilo; por eso no esSend. - Dispositivo predeterminado:
device_id: Noneusa el primer dispositivo de la lista de Media Foundation. - Fotogramas:
VideoFrameno contienepixel_buffer(esNone).
Licencia
Apache-2.0