use std::ffi::{OsStr, c_void};
use std::fs;
use std::os::fd::{AsRawFd, OwnedFd};
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use ffmpeg_next as ffmpeg;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct V4l2Device {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct V4l2CaptureFormat {
pub width: u32,
pub height: u32,
pub framerate: ffmpeg::Rational,
}
pub fn list_devices() -> std::io::Result<Vec<V4l2Device>> {
let mut nodes: Vec<PathBuf> = fs::read_dir("/dev")?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| is_video_node(path))
.collect();
nodes.sort();
Ok(nodes
.into_iter()
.filter_map(|path| {
let file = std::fs::File::open(&path).ok()?;
let capability = query_capability(&file).ok()?;
capability.captures_video().then(|| V4l2Device {
name: capability
.card()
.unwrap_or_else(|| path.display().to_string()),
id: path.display().to_string(),
})
})
.collect())
}
pub fn format_name_for(
device: &str,
width: u32,
height: u32,
framerate: ffmpeg::Rational,
) -> Option<&'static str> {
let file = std::fs::File::open(device).ok()?;
let mut compressed = None;
for pixel_format in pixel_formats(&file) {
let offered =
frame_sizes(&file, pixel_format)
.into_iter()
.any(|(offered_width, offered_height)| {
offered_width == width && offered_height == height
})
&& frame_rates(&file, pixel_format, width, height).contains(&framerate);
if !offered {
continue;
}
match demuxer_name(pixel_format) {
Some(name) if !is_compressed(pixel_format) => return Some(name),
Some(name) => compressed = compressed.or(Some(name)),
None => {}
}
}
compressed
}
fn demuxer_name(pixel_format: u32) -> Option<&'static str> {
Some(match &pixel_format.to_le_bytes() {
b"YUYV" => "yuyv422",
b"UYVY" => "uyvy422",
b"NV12" => "nv12",
b"YU12" => "yuv420p",
b"RGB3" => "rgb24",
b"BGR3" => "bgr24",
b"MJPG" => "mjpeg",
_ => return None,
})
}
fn is_compressed(pixel_format: u32) -> bool {
matches!(&pixel_format.to_le_bytes(), b"MJPG" | b"JPEG" | b"H264")
}
pub fn list_formats(device: &str) -> std::io::Result<Vec<V4l2CaptureFormat>> {
let file = std::fs::File::open(device)?;
let mut formats = Vec::new();
for pixel_format in pixel_formats(&file) {
for (width, height) in frame_sizes(&file, pixel_format) {
for framerate in frame_rates(&file, pixel_format, width, height) {
let mode = V4l2CaptureFormat {
width,
height,
framerate,
};
if !formats.contains(&mode) {
formats.push(mode);
}
}
}
}
formats.sort_by(|left, right| {
let area = |mode: &V4l2CaptureFormat| u64::from(mode.width) * u64::from(mode.height);
let fps = |mode: &V4l2CaptureFormat| {
f64::from(mode.framerate.numerator()) / f64::from(mode.framerate.denominator().max(1))
};
area(right)
.cmp(&area(left))
.then_with(|| fps(right).total_cmp(&fps(left)))
});
Ok(formats)
}
fn is_video_node(path: &Path) -> bool {
path.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| {
name.strip_prefix("video").is_some_and(|rest| {
!rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit())
})
})
}
fn query_capability(file: &std::fs::File) -> std::io::Result<Capability> {
let mut capability = Capability::default();
call(file, VIDIOC_QUERYCAP, &mut capability)?;
Ok(capability)
}
fn pixel_formats(file: &std::fs::File) -> Vec<u32> {
(0u32..)
.map_while(|index| {
let mut description = FmtDesc {
index,
kind: BUF_TYPE_VIDEO_CAPTURE,
..FmtDesc::default()
};
call(file, VIDIOC_ENUM_FMT, &mut description)
.ok()
.map(|()| description.pixel_format)
})
.collect()
}
fn frame_sizes(file: &std::fs::File, pixel_format: u32) -> Vec<(u32, u32)> {
(0u32..)
.map_while(|index| {
let mut sizes = FrameSizeEnum {
index,
pixel_format,
..FrameSizeEnum::default()
};
call(file, VIDIOC_ENUM_FRAMESIZES, &mut sizes).ok()?;
Some(sizes)
})
.filter(|sizes| sizes.kind == FRMSIZE_TYPE_DISCRETE)
.map(|sizes| (sizes.width, sizes.height))
.collect()
}
fn frame_rates(
file: &std::fs::File,
pixel_format: u32,
width: u32,
height: u32,
) -> Vec<ffmpeg::Rational> {
(0u32..)
.map_while(|index| {
let mut intervals = FrameIntervalEnum {
index,
pixel_format,
width,
height,
..FrameIntervalEnum::default()
};
call(file, VIDIOC_ENUM_FRAMEINTERVALS, &mut intervals).ok()?;
Some(intervals)
})
.filter(|intervals| intervals.kind == FRMIVAL_TYPE_DISCRETE)
.filter(|intervals| intervals.numerator > 0)
.map(|intervals| {
ffmpeg::Rational::new(intervals.denominator as i32, intervals.numerator as i32)
})
.collect()
}
fn call<T>(file: &std::fs::File, request: libc::c_ulong, argument: &mut T) -> std::io::Result<()> {
let code = unsafe {
libc::ioctl(
file.as_raw_fd(),
request,
std::ptr::from_mut(argument).cast::<c_void>(),
)
};
if code < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
const fn request(direction: u32, ordinal: u32, size: usize) -> libc::c_ulong {
((direction << 30) | ((size as u32) << 16) | (b'V' as u32) << 8 | ordinal) as libc::c_ulong
}
const READ: u32 = 2;
const READ_WRITE: u32 = 3;
const VIDIOC_QUERYCAP: libc::c_ulong = request(READ, 0, size_of::<Capability>());
const VIDIOC_ENUM_FMT: libc::c_ulong = request(READ_WRITE, 2, size_of::<FmtDesc>());
const VIDIOC_ENUM_FRAMESIZES: libc::c_ulong = request(READ_WRITE, 74, size_of::<FrameSizeEnum>());
const VIDIOC_ENUM_FRAMEINTERVALS: libc::c_ulong =
request(READ_WRITE, 75, size_of::<FrameIntervalEnum>());
const BUF_TYPE_VIDEO_CAPTURE: u32 = 1;
const CAP_VIDEO_CAPTURE: u32 = 0x0000_0001;
const CAP_DEVICE_CAPS: u32 = 0x8000_0000;
const FRMSIZE_TYPE_DISCRETE: u32 = 1;
const FRMIVAL_TYPE_DISCRETE: u32 = 1;
#[repr(C)]
#[derive(Default)]
struct Capability {
driver: [u8; 16],
card: [u8; 32],
bus_info: [u8; 32],
version: u32,
capabilities: u32,
device_caps: u32,
reserved: [u32; 3],
}
impl Capability {
fn captures_video(&self) -> bool {
let own = if self.capabilities & CAP_DEVICE_CAPS != 0 {
self.device_caps
} else {
self.capabilities
};
own & CAP_VIDEO_CAPTURE != 0
}
fn card(&self) -> Option<String> {
let end = self.card.iter().position(|byte| *byte == 0)?;
let name = OsStr::from_bytes(&self.card[..end])
.to_string_lossy()
.trim()
.to_owned();
(!name.is_empty()).then_some(name)
}
}
#[repr(C)]
#[derive(Default)]
struct FmtDesc {
index: u32,
kind: u32,
flags: u32,
description: [u8; 32],
pixel_format: u32,
mbus_code: u32,
reserved: [u32; 3],
}
#[repr(C)]
#[derive(Default)]
struct FrameSizeEnum {
index: u32,
pixel_format: u32,
kind: u32,
width: u32,
height: u32,
stepwise_tail: [u32; 4],
reserved: [u32; 2],
}
#[repr(C)]
#[derive(Default)]
struct FrameIntervalEnum {
index: u32,
pixel_format: u32,
width: u32,
height: u32,
kind: u32,
numerator: u32,
denominator: u32,
stepwise_tail: [u32; 4],
reserved: [u32; 2],
}
#[allow(dead_code)]
type Node = OwnedFd;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_request_numbers_match_the_kernels() {
assert_eq!(VIDIOC_QUERYCAP, 0x8068_5600);
assert_eq!(VIDIOC_ENUM_FMT, 0xC040_5602);
assert_eq!(VIDIOC_ENUM_FRAMESIZES, 0xC02C_564A);
assert_eq!(VIDIOC_ENUM_FRAMEINTERVALS, 0xC034_564B);
}
#[test]
fn every_camera_offered_names_itself() {
let devices = list_devices().expect("/dev is readable");
for device in &devices {
assert!(device.id.starts_with("/dev/video"), "{device:?}");
assert!(!device.name.is_empty(), "{device:?}");
}
let Some(first) = devices.first() else {
eprintln!("skipping: this machine has no camera");
return;
};
for mode in list_formats(&first.id).expect("a camera that is there") {
assert!(mode.width > 0 && mode.height > 0, "{mode:?}");
assert!(mode.framerate.numerator() > 0, "{mode:?}");
}
}
}