From 1c19ee93b659042e6fdf8c963c620a09ddd830df Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:46:16 -0700 Subject: [PATCH 1/8] Add source feature --- livekit-capture/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/livekit-capture/Cargo.toml b/livekit-capture/Cargo.toml index 99986417b..50e4f9d1d 100644 --- a/livekit-capture/Cargo.toml +++ b/livekit-capture/Cargo.toml @@ -49,6 +49,7 @@ schemars = ["dep:schemars", "serde"] # Pixel sources source-clock = ["dep:chrono", "dep:pollster", "dep:wgpu", "dep:yuv-sys"] +source-device = [] source-pattern = ["dep:pollster", "dep:wgpu", "dep:yuv-sys"] # Encoded sources From b3f61de26456616fe5415e95fcb6886ca6ef7985 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:46:17 -0700 Subject: [PATCH 2/8] Implement source --- livekit-capture/src/sources/device/mod.rs | 601 ++++++++++++++++++++++ livekit-capture/src/sources/mod.rs | 3 + 2 files changed, 604 insertions(+) create mode 100644 livekit-capture/src/sources/device/mod.rs diff --git a/livekit-capture/src/sources/device/mod.rs b/livekit-capture/src/sources/device/mod.rs new file mode 100644 index 000000000..e2eea9687 --- /dev/null +++ b/livekit-capture/src/sources/device/mod.rs @@ -0,0 +1,601 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Camera device capture. +//! +//! [`DeviceVideoSource`] captures pixel frames from a video device through +//! the platform's native capture stack. Configuration, enumeration +//! ([`devices`]), and errors use one platform-neutral vocabulary. On +//! platforms without a backend the module still compiles, and construction +//! and enumeration fail with +//! [`DeviceVideoSourceError::UnsupportedPlatform`]. +//! +//! Where the platform supports it, frames reach the RTC track as +//! platform-native buffers without a CPU copy. Otherwise they are converted +//! to I420. + +use unsupported as backend; + +use std::fmt; + +use livekit::webrtc::video_frame::BoxVideoFrame; +use thiserror::Error; + +use crate::{ + error::SourceError, pixel::PixelVideoSource, primitive::VideoResolution, pump::PumpStop, +}; + +/// Selects the video device a [`DeviceVideoSource`] captures from. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(rename_all = "lowercase") +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum DeviceSelector { + /// The platform default video device. + #[default] + Default, + /// The device at this position in the platform enumeration order. + Index(usize), + /// The device with this identifier, as reported by [`DeviceInfo::id`]. + /// + /// Identifiers are backend-specific and treated as opaque; prefer + /// [`DeviceInfo::selector`] over constructing them. On Linux, Jetson CSI + /// sensors captured through libargus use the `argus:N` namespace. + Id(String), +} + +/// Frame format delivered by a capture device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(rename_all = "lowercase") +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum DeviceFrameFormat { + /// Planar I420/YUV420P. + I420, + /// Biplanar NV12. + Nv12, + /// Packed BGRA. + Bgra, + /// Packed RGB24. + Rgb24, + /// Packed BGR24. + Bgr24, + /// Packed YUYV/YUY2. + Yuyv, + /// Packed UYVY. + Uyvy, + /// Single-plane 8-bit luma. + Grey, + /// Encoded MJPEG frames. + Mjpeg, +} + +impl DeviceFrameFormat { + /// Returns a stable lower-case frame-format name. + pub const fn as_str(self) -> &'static str { + match self { + Self::I420 => "i420", + Self::Nv12 => "nv12", + Self::Bgra => "bgra", + Self::Rgb24 => "rgb24", + Self::Bgr24 => "bgr24", + Self::Yuyv => "yuyv", + Self::Uyvy => "uyvy", + Self::Grey => "grey", + Self::Mjpeg => "mjpeg", + } + } +} + +impl fmt::Display for DeviceFrameFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for DeviceFrameFormat { + type Err = DeviceFrameFormatParseError; + + fn from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "i420" | "yuv420p" => Ok(Self::I420), + "nv12" => Ok(Self::Nv12), + "bgra" => Ok(Self::Bgra), + "rgb24" | "rgb" => Ok(Self::Rgb24), + "bgr24" | "bgr" => Ok(Self::Bgr24), + "yuyv" | "yuy2" => Ok(Self::Yuyv), + "uyvy" => Ok(Self::Uyvy), + "grey" | "greyscale" => Ok(Self::Grey), + "mjpeg" | "mjpg" => Ok(Self::Mjpeg), + _ => Err(DeviceFrameFormatParseError), + } + } +} + +/// Error returned when parsing a [`DeviceFrameFormat`] from a string. +#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)] +#[error("unknown device frame format")] +pub struct DeviceFrameFormatParseError; + +/// Capture format offered by a device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(deny_unknown_fields) +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct DeviceFormat { + /// Frame dimensions. + pub resolution: VideoResolution, + /// Frame rate in frames per second. + pub framerate_fps: u32, + /// Frame format. + pub frame_format: DeviceFrameFormat, +} + +impl DeviceFormat { + /// Creates a device capture format. + pub const fn new( + resolution: VideoResolution, + framerate_fps: u32, + frame_format: DeviceFrameFormat, + ) -> Self { + Self { resolution, framerate_fps, frame_format } + } +} + +impl fmt::Display for DeviceFormat { + /// Formats as `WIDTHxHEIGHT@FPSfps FORMAT`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}@{}fps {}", self.resolution, self.framerate_fps, self.frame_format) + } +} + +/// Format selection requested from a capture device. +/// +/// The device negotiates the delivered format, and +/// [`DeviceVideoSource::format`] reports the outcome. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(rename_all = "snake_case") +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum DeviceFormatRequest { + /// Let the device choose its default format. + #[default] + Default, + /// Require an exact format match. + Exact(DeviceFormat), + /// Use the device's closest supported format. + Closest(DeviceFormat), + /// Prefer the highest frame rate, optionally constrained by resolution + /// and frame format. + HighestFramerate { + /// Optional resolution constraint. + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] + resolution: Option, + /// Optional frame format constraint. + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] + frame_format: Option, + }, + /// Prefer the highest resolution, optionally constrained by frame rate + /// and frame format. + HighestResolution { + /// Optional frame-rate constraint. + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] + framerate_fps: Option, + /// Optional frame format constraint. + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] + frame_format: Option, + }, +} + +/// Video capture device discovered by [`devices`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(deny_unknown_fields) +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct DeviceInfo { + /// Device identifier, usable with [`DeviceSelector::Id`]. + pub id: String, + /// Human-readable device name. + pub name: String, + /// Device model identifier, when available. + pub model_id: Option, + /// Device manufacturer, when available. + pub manufacturer: Option, + /// Capture formats reported by the device. + pub formats: Vec, + /// Whether [`DeviceInfo::formats`] is a complete list. Some platforms do + /// not enumerate formats up front. + pub formats_complete: bool, +} + +impl DeviceInfo { + /// Returns the selector that reopens this exact device. + pub fn selector(&self) -> DeviceSelector { + DeviceSelector::Id(self.id.clone()) + } +} + +/// Lists the video capture devices on this machine. +/// +/// Requires a running tokio runtime: enumeration runs on the tokio blocking +/// pool. Use [`devices_blocking`] outside of async contexts. +#[cfg(feature = "tokio")] +pub async fn devices() -> Result, SourceError> { + crate::utils::run_blocking(devices_blocking).await +} + +/// Lists the video capture devices on this machine. +/// +/// Enumeration queries the platform capture stack and can block briefly. +pub fn devices_blocking() -> Result, SourceError> { + backend::devices().map_err(SourceError::new) +} + +/// Configuration for a [`DeviceVideoSource`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(deny_unknown_fields) +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct DeviceVideoSourceConfig { + /// Device to capture from. + #[cfg_attr(feature = "serde", serde(default))] + pub device: DeviceSelector, + /// Format requested from the device. + #[cfg_attr(feature = "serde", serde(default))] + pub format: DeviceFormatRequest, +} + +/// Pixel video source that captures frames from a video device, such as a +/// camera. +/// +/// Construction opens the device and negotiates the capture format, so +/// [`DeviceVideoSource::format`] is known before any frame is pumped. The +/// source never reaches the end of its stream — stop the pump that drives +/// it instead. +/// +/// Frames carry a monotonic `timestamp_us`. Each frame's `frame_metadata` +/// is pre-filled with the wall-clock capture time — the device's own +/// capture timestamp when the platform reports a valid one. +pub struct DeviceVideoSource { + config: DeviceVideoSourceConfig, + format: DeviceFormat, + session: backend::Session, +} + +impl DeviceVideoSource { + /// Creates the source. Device negotiation runs on the tokio blocking + /// pool. + /// + /// Requires a running tokio runtime. Use + /// [`DeviceVideoSource::new_blocking`] outside of async contexts. + #[cfg(feature = "tokio")] + pub async fn new(config: DeviceVideoSourceConfig) -> Result { + crate::utils::run_blocking(move || Self::new_blocking(config)).await + } + + /// Opens the configured device and negotiates the capture format. + /// + /// This can block until the device delivers its first frame, bounded by + /// a timeout. Construction fails on a missing device, a format request + /// the device cannot satisfy, or a platform without a capture backend. + pub fn new_blocking(config: DeviceVideoSourceConfig) -> Result { + let session = backend::Session::open(&config).map_err(SourceError::new)?; + let format = session.format(); + Ok(Self { config, format, session }) + } + + /// Returns the configuration the source was created with. + pub fn config(&self) -> &DeviceVideoSourceConfig { + &self.config + } + + /// Returns the negotiated capture format. + /// + /// The resolution matches what [`PixelVideoSource::resolution`] reports. + /// The frame format is what the device delivers before any conversion. + pub fn format(&self) -> DeviceFormat { + self.format + } +} + +impl PixelVideoSource for DeviceVideoSource { + fn resolution(&self) -> VideoResolution { + self.format.resolution + } + + // Backends bound every blocking wait so the stop token is observed + // within ~100ms even when the device stalls. + fn next_frame(&mut self, stop: &PumpStop) -> Result, SourceError> { + self.session.next_frame(stop).map_err(SourceError::new) + } +} + +impl fmt::Debug for DeviceVideoSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DeviceVideoSource") + .field("config", &self.config) + .field("format", &self.format) + .finish_non_exhaustive() + } +} + +/// Error returned by device capture. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum DeviceVideoSourceError { + /// Device capture has no backend for this platform. + #[error("device capture is not supported on this platform")] + UnsupportedPlatform, + /// The requested device was not found. + #[error("capture device was not found")] + DeviceNotFound, + /// The configuration is invalid. + #[error("invalid device source configuration: {0}")] + InvalidConfig(&'static str), + /// The requested frame format is not supported by this platform's + /// backend. + #[error("device capture does not support frame format {0} on this platform")] + UnsupportedFrameFormat(DeviceFrameFormat), + /// The requested capture format is not available on the selected device. + #[error("capture format is not available on the device: {0}")] + UnsupportedFormat(DeviceFormat), + /// Timed out waiting for the device to deliver a frame. + #[error("timed out waiting for a frame from the capture device")] + FrameTimeout, + /// Captured frame bytes did not match the negotiated format. + #[error("invalid captured frame: {0}")] + InvalidFrame(&'static str), + /// Pixel conversion failed. + #[error("failed to convert captured frame to I420: {0}")] + Convert(&'static str), + /// Compressed frame decoding failed. + #[error("failed to decode compressed frame: {0}")] + Decode(String), + /// The platform capture stack reported an error. + #[error("capture device error: {0}")] + Backend(String), +} + +/// Builds the packet-trailer metadata that device frames are pre-filled +/// with. A metadata callback set on the pump takes precedence. +#[allow(dead_code)] +fn capture_frame_metadata( + capture_wall_time_us: u64, +) -> livekit::webrtc::video_frame::FrameMetadata { + livekit::webrtc::video_frame::FrameMetadata { + user_timestamp: Some(capture_wall_time_us), + frame_id: None, + user_data: None, + } +} + +/// Validates the platform-neutral parts of a configuration; `supported` +/// reports whether the backend can deliver a frame format. +#[allow(dead_code)] +fn validate_config( + config: &DeviceVideoSourceConfig, + supported: fn(DeviceFrameFormat) -> bool, +) -> Result<(), DeviceVideoSourceError> { + if let DeviceSelector::Id(id) = &config.device { + if id.is_empty() { + return Err(DeviceVideoSourceError::InvalidConfig("device id must be non-empty")); + } + } + + let validate_frame_format = |frame_format: DeviceFrameFormat| { + if !supported(frame_format) { + return Err(DeviceVideoSourceError::UnsupportedFrameFormat(frame_format)); + } + Ok(()) + }; + let validate_resolution = |resolution: VideoResolution| { + if resolution.width == 0 { + return Err(DeviceVideoSourceError::InvalidConfig("width must be non-zero")); + } + if resolution.height == 0 { + return Err(DeviceVideoSourceError::InvalidConfig("height must be non-zero")); + } + Ok(()) + }; + + match &config.format { + DeviceFormatRequest::Default => Ok(()), + DeviceFormatRequest::Exact(format) | DeviceFormatRequest::Closest(format) => { + validate_resolution(format.resolution)?; + if format.framerate_fps == 0 { + return Err(DeviceVideoSourceError::InvalidConfig( + "framerate_fps must be non-zero", + )); + } + validate_frame_format(format.frame_format) + } + DeviceFormatRequest::HighestFramerate { resolution, frame_format } => { + if let Some(resolution) = resolution { + validate_resolution(*resolution)?; + } + if let Some(frame_format) = frame_format { + validate_frame_format(*frame_format)?; + } + Ok(()) + } + DeviceFormatRequest::HighestResolution { framerate_fps, frame_format } => { + if matches!(framerate_fps, Some(0)) { + return Err(DeviceVideoSourceError::InvalidConfig( + "framerate_fps must be non-zero", + )); + } + if let Some(frame_format) = frame_format { + validate_frame_format(*frame_format)?; + } + Ok(()) + } + } +} + +/// Stub backend for platforms without device capture. +mod unsupported { + use livekit::webrtc::video_frame::BoxVideoFrame; + + use super::{DeviceFormat, DeviceInfo, DeviceVideoSourceConfig, DeviceVideoSourceError}; + use crate::pump::PumpStop; + + /// Uninhabited: [`Session::open`] always fails on this platform. + #[derive(Debug)] + pub(super) enum Session {} + + impl Session { + pub(super) fn open( + _config: &DeviceVideoSourceConfig, + ) -> Result { + Err(DeviceVideoSourceError::UnsupportedPlatform) + } + + pub(super) fn format(&self) -> DeviceFormat { + match *self {} + } + + pub(super) fn next_frame( + &mut self, + _stop: &PumpStop, + ) -> Result, DeviceVideoSourceError> { + match *self {} + } + } + + pub(super) fn devices() -> Result, DeviceVideoSourceError> { + Err(DeviceVideoSourceError::UnsupportedPlatform) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + fn any_supported(_: DeviceFrameFormat) -> bool { + true + } + + #[test] + fn frame_format_parses_common_names() { + assert_eq!(DeviceFrameFormat::from_str("MJPEG"), Ok(DeviceFrameFormat::Mjpeg)); + assert_eq!(DeviceFrameFormat::from_str("mjpg"), Ok(DeviceFrameFormat::Mjpeg)); + assert_eq!(DeviceFrameFormat::from_str("grey"), Ok(DeviceFrameFormat::Grey)); + assert_eq!(DeviceFrameFormat::from_str("GREY"), Ok(DeviceFrameFormat::Grey)); + assert_eq!(DeviceFrameFormat::from_str("yuy2"), Ok(DeviceFrameFormat::Yuyv)); + } + + #[test] + fn frame_format_displays_canonical_names() { + assert_eq!(DeviceFrameFormat::Mjpeg.to_string(), "mjpeg"); + assert_eq!(DeviceFrameFormat::Grey.to_string(), "grey"); + } + + #[test] + fn validation_rejects_empty_device_id() { + let config = DeviceVideoSourceConfig { + device: DeviceSelector::Id(String::new()), + format: DeviceFormatRequest::Default, + }; + assert!(matches!( + validate_config(&config, any_supported), + Err(DeviceVideoSourceError::InvalidConfig(_)) + )); + } + + #[test] + fn validation_rejects_zero_format_components() { + let zero_width = DeviceVideoSourceConfig { + device: DeviceSelector::Default, + format: DeviceFormatRequest::Exact(DeviceFormat::new( + VideoResolution::new(0, 720), + 30, + DeviceFrameFormat::Yuyv, + )), + }; + assert!(matches!( + validate_config(&zero_width, any_supported), + Err(DeviceVideoSourceError::InvalidConfig(_)) + )); + + let zero_framerate = DeviceVideoSourceConfig { + device: DeviceSelector::Default, + format: DeviceFormatRequest::HighestResolution { + framerate_fps: Some(0), + frame_format: None, + }, + }; + assert!(matches!( + validate_config(&zero_framerate, any_supported), + Err(DeviceVideoSourceError::InvalidConfig(_)) + )); + } + + #[test] + fn validation_rejects_unsupported_frame_formats() { + let config = DeviceVideoSourceConfig { + device: DeviceSelector::Default, + format: DeviceFormatRequest::HighestFramerate { + resolution: None, + frame_format: Some(DeviceFrameFormat::Uyvy), + }, + }; + assert!(matches!( + validate_config(&config, |format| format != DeviceFrameFormat::Uyvy), + Err(DeviceVideoSourceError::UnsupportedFrameFormat(DeviceFrameFormat::Uyvy)) + )); + } + + #[test] + fn default_config_requests_default_device_and_format() { + let config = DeviceVideoSourceConfig::default(); + assert_eq!(config.device, DeviceSelector::Default); + assert_eq!(config.format, DeviceFormatRequest::Default); + } + + #[test] + fn device_info_selector_reopens_by_id() { + let info = DeviceInfo { + id: "camera-0".to_string(), + name: "Camera".to_string(), + model_id: None, + manufacturer: None, + formats: Vec::new(), + formats_complete: false, + }; + assert_eq!(info.selector(), DeviceSelector::Id("camera-0".to_string())); + } +} diff --git a/livekit-capture/src/sources/mod.rs b/livekit-capture/src/sources/mod.rs index 377a0ff73..77025f2da 100644 --- a/livekit-capture/src/sources/mod.rs +++ b/livekit-capture/src/sources/mod.rs @@ -18,6 +18,9 @@ #[cfg(feature = "source-clock")] pub mod clock; +#[cfg(feature = "source-device")] +pub mod device; + #[cfg(feature = "source-gstreamer")] pub mod gstreamer; From 994694ade6be8944bf031bf1bfb1480b4bf9ff1f Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:46:17 -0700 Subject: [PATCH 3/8] Document new source --- livekit-capture/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/livekit-capture/README.md b/livekit-capture/README.md index d60db3203..0e7db7e1c 100644 --- a/livekit-capture/README.md +++ b/livekit-capture/README.md @@ -56,6 +56,7 @@ named `source-`. Each module documents its source. | Feature | Source | Kind | | ------------------ | ---------------------- | ------- | +| `source-device` | `DeviceVideoSource` | pixel | | `source-gstreamer` | `GStreamerVideoSource` | encoded | | `source-rtsp` | `RtspVideoSource` | encoded | | `source-pattern` | `PatternVideoSource` | pixel | From 8f6b7878ac8a53b7a445c71f9867d52c3e738c06 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:21:25 -0700 Subject: [PATCH 4/8] Expose source over FFI --- livekit-ffi/Cargo.toml | 1 + livekit-ffi/protocol/capture.proto | 94 ++++++++++++++++++++ livekit-ffi/protocol/ffi.proto | 9 +- livekit-ffi/src/conversion/capture.rs | 118 ++++++++++++++++++++++++++ livekit-ffi/src/server/capture.rs | 34 ++++++++ livekit-ffi/src/server/requests.rs | 13 ++- 6 files changed, 265 insertions(+), 4 deletions(-) diff --git a/livekit-ffi/Cargo.toml b/livekit-ffi/Cargo.toml index ea56206aa..c5a30ea1a 100644 --- a/livekit-ffi/Cargo.toml +++ b/livekit-ffi/Cargo.toml @@ -24,6 +24,7 @@ tracing = ["tokio/tracing", "console-subscriber"] # `capture-*` feature to include a specific source. capture = ["dep:livekit-capture"] capture-clock = ["capture", "livekit-capture/source-clock"] +capture-device = ["capture", "livekit-capture/source-device"] capture-gstreamer = ["capture", "livekit-capture/source-gstreamer"] capture-pattern = ["capture", "livekit-capture/source-pattern"] capture-rtsp = ["capture", "livekit-capture/source-rtsp"] diff --git a/livekit-ffi/protocol/capture.proto b/livekit-ffi/protocol/capture.proto index b4ddadc68..5a61904ba 100644 --- a/livekit-ffi/protocol/capture.proto +++ b/livekit-ffi/protocol/capture.proto @@ -127,6 +127,99 @@ message ClockVideoSourceConfig { required uint32 framerate_fps = 2; } +// Frame format delivered by a capture device. +enum DeviceFrameFormat { + DEVICE_FRAME_FORMAT_I420 = 0; + DEVICE_FRAME_FORMAT_NV12 = 1; + DEVICE_FRAME_FORMAT_BGRA = 2; + DEVICE_FRAME_FORMAT_RGB24 = 3; + DEVICE_FRAME_FORMAT_BGR24 = 4; + DEVICE_FRAME_FORMAT_YUYV = 5; + DEVICE_FRAME_FORMAT_UYVY = 6; + DEVICE_FRAME_FORMAT_GREY = 7; + DEVICE_FRAME_FORMAT_MJPEG = 8; +} + +// Capture format offered by or requested from a device. +message DeviceFormat { + // Frame dimensions. + required VideoSourceResolution resolution = 1; + // Frame rate in frames per second. + required uint32 framerate_fps = 2; + // Frame format. + required DeviceFrameFormat frame_format = 3; +} + +// Format selection requested from a capture device. The device negotiates +// the delivered format; CaptureSourceInfo reports the outcome. +message DeviceFormatRequest { + // Prefer the highest frame rate, optionally constrained. + message HighestFramerate { + optional VideoSourceResolution resolution = 1; + optional DeviceFrameFormat frame_format = 2; + } + // Prefer the highest resolution, optionally constrained. + message HighestResolution { + optional uint32 framerate_fps = 1; + optional DeviceFrameFormat frame_format = 2; + } + // The device's default format when unset. + oneof request { + // Require an exact format match. + DeviceFormat exact = 1; + // Use the device's closest supported format. + DeviceFormat closest = 2; + HighestFramerate highest_framerate = 3; + HighestResolution highest_resolution = 4; + } +} + +// Camera device capture using the platform's native capture stack. +message DeviceVideoSourceConfig { + // Device to capture from; the platform default device when unset. + oneof device { + // Position in the platform enumeration order. + uint32 device_index = 1; + // Platform-stable identifier, as reported by CaptureDeviceInfo.id. + string device_id = 2; + } + // Format requested from the device; the device default when unset. + optional DeviceFormatRequest format = 3; +} + +// Video capture device discovered by ListCaptureDevicesRequest. +message CaptureDeviceInfo { + // Platform-stable device identifier. + required string id = 1; + // Human-readable device name. + required string name = 2; + // Device model identifier, when available. + optional string model_id = 3; + // Device manufacturer, when available. + optional string manufacturer = 4; + // Capture formats reported by the device. + repeated DeviceFormat formats = 5; + // Whether `formats` is a complete list; some platforms do not enumerate + // formats up front. + required bool formats_complete = 6; +} + +message CaptureDeviceList { repeated CaptureDeviceInfo devices = 1; } + +// List the video capture devices available on this machine. +// +// Completes asynchronously with a ListCaptureDevicesCallback: enumeration +// queries the platform capture stack and may block briefly. +message ListCaptureDevicesRequest { optional uint64 request_async_id = 1; } +message ListCaptureDevicesResponse { required uint64 async_id = 1; } +message ListCaptureDevicesCallback { + required uint64 async_id = 1; + oneof message { + string error = 2; + CaptureDeviceList devices = 3; + } +} + // Kind of media a capture source produces. enum CaptureSourceKind { // Pixel frames, published through the WebRTC encoder. @@ -164,6 +257,7 @@ message NewCaptureSourceRequest { oneof config { GstreamerVideoSourceConfig gstreamer = 1; PatternVideoSourceConfig pattern = 2; + DeviceVideoSourceConfig device = 4; ClockVideoSourceConfig clock = 5; RtspVideoSourceConfig rtsp = 6; } diff --git a/livekit-ffi/protocol/ffi.proto b/livekit-ffi/protocol/ffi.proto index c4461bc7e..b90052eaa 100644 --- a/livekit-ffi/protocol/ffi.proto +++ b/livekit-ffi/protocol/ffi.proto @@ -193,8 +193,9 @@ message FfiRequest { NewCaptureSourceRequest new_capture_source = 91; StartCaptureRequest start_capture = 89; StopCaptureRequest stop_capture = 90; + ListCaptureDevicesRequest list_capture_devices = 92; - // NEXT_ID: 92 + // NEXT_ID: 93 } } @@ -331,8 +332,9 @@ message FfiResponse { NewCaptureSourceResponse new_capture_source = 91; StartCaptureResponse start_capture = 89; StopCaptureResponse stop_capture = 90; + ListCaptureDevicesResponse list_capture_devices = 92; - // NEXT_ID: 92 + // NEXT_ID: 93 } } @@ -404,8 +406,9 @@ message FfiEvent { // Capture sources (livekit-capture; requires the `capture` feature) NewCaptureSourceCallback new_capture_source = 47; CaptureSourceEvent capture_source_event = 48; + ListCaptureDevicesCallback list_capture_devices = 49; - // NEXT_ID: 49 + // NEXT_ID: 50 } } diff --git a/livekit-ffi/src/conversion/capture.rs b/livekit-ffi/src/conversion/capture.rs index 5bcb3071e..5a518cf6b 100644 --- a/livekit-ffi/src/conversion/capture.rs +++ b/livekit-ffi/src/conversion/capture.rs @@ -16,6 +16,7 @@ use crate::proto; use livekit_capture::{encoded::EncodedVideoCodec, primitive::VideoResolution}; #[cfg(any( + feature = "capture-device", feature = "capture-gstreamer", feature = "capture-pattern", feature = "capture-rtsp" @@ -23,6 +24,11 @@ use livekit_capture::{encoded::EncodedVideoCodec, primitive::VideoResolution}; use crate::{FfiError, FfiResult}; #[cfg(feature = "capture-clock")] use livekit_capture::sources::clock::ClockVideoSourceConfig; +#[cfg(feature = "capture-device")] +use livekit_capture::sources::device::{ + DeviceFormat, DeviceFormatRequest, DeviceFrameFormat, DeviceInfo, DeviceSelector, + DeviceVideoSourceConfig, +}; #[cfg(feature = "capture-gstreamer")] use livekit_capture::sources::gstreamer::{ GStreamerBitrateUnit, GStreamerRateControlConfig, GStreamerVideoSourceConfig, @@ -95,6 +101,118 @@ pub fn video_codec_to_proto(codec: EncodedVideoCodec) -> Option DeviceFrameFormat { + match format { + proto::DeviceFrameFormat::I420 => DeviceFrameFormat::I420, + proto::DeviceFrameFormat::Nv12 => DeviceFrameFormat::Nv12, + proto::DeviceFrameFormat::Bgra => DeviceFrameFormat::Bgra, + proto::DeviceFrameFormat::Rgb24 => DeviceFrameFormat::Rgb24, + proto::DeviceFrameFormat::Bgr24 => DeviceFrameFormat::Bgr24, + proto::DeviceFrameFormat::Yuyv => DeviceFrameFormat::Yuyv, + proto::DeviceFrameFormat::Uyvy => DeviceFrameFormat::Uyvy, + proto::DeviceFrameFormat::Grey => DeviceFrameFormat::Grey, + proto::DeviceFrameFormat::Mjpeg => DeviceFrameFormat::Mjpeg, + } +} + +#[cfg(feature = "capture-device")] +fn device_frame_format_to_proto(format: DeviceFrameFormat) -> Option { + match format { + DeviceFrameFormat::I420 => Some(proto::DeviceFrameFormat::I420), + DeviceFrameFormat::Nv12 => Some(proto::DeviceFrameFormat::Nv12), + DeviceFrameFormat::Bgra => Some(proto::DeviceFrameFormat::Bgra), + DeviceFrameFormat::Rgb24 => Some(proto::DeviceFrameFormat::Rgb24), + DeviceFrameFormat::Bgr24 => Some(proto::DeviceFrameFormat::Bgr24), + DeviceFrameFormat::Yuyv => Some(proto::DeviceFrameFormat::Yuyv), + DeviceFrameFormat::Uyvy => Some(proto::DeviceFrameFormat::Uyvy), + DeviceFrameFormat::Grey => Some(proto::DeviceFrameFormat::Grey), + DeviceFrameFormat::Mjpeg => Some(proto::DeviceFrameFormat::Mjpeg), + // The frame format enum is non-exhaustive; formats unknown to the + // protocol are simply not reported. + _ => None, + } +} + +#[cfg(feature = "capture-device")] +fn decode_device_frame_format(value: i32) -> FfiResult { + proto::DeviceFrameFormat::try_from(value) + .map(device_frame_format_from_proto) + .map_err(|_| FfiError::InvalidRequest("invalid device frame format".into())) +} + +#[cfg(feature = "capture-device")] +fn device_format_from_proto(format: proto::DeviceFormat) -> FfiResult { + Ok(DeviceFormat { + resolution: format.resolution.into(), + framerate_fps: format.framerate_fps, + frame_format: decode_device_frame_format(format.frame_format)?, + }) +} + +#[cfg(feature = "capture-device")] +fn device_format_to_proto(format: DeviceFormat) -> Option { + Some(proto::DeviceFormat { + resolution: proto::VideoSourceResolution { + width: format.resolution.width, + height: format.resolution.height, + }, + framerate_fps: format.framerate_fps, + frame_format: device_frame_format_to_proto(format.frame_format)?.into(), + }) +} + +#[cfg(feature = "capture-device")] +fn device_format_request_from_proto( + request: proto::DeviceFormatRequest, +) -> FfiResult { + use proto::device_format_request::Request; + Ok(match request.request { + None => DeviceFormatRequest::Default, + Some(Request::Exact(format)) => { + DeviceFormatRequest::Exact(device_format_from_proto(format)?) + } + Some(Request::Closest(format)) => { + DeviceFormatRequest::Closest(device_format_from_proto(format)?) + } + Some(Request::HighestFramerate(constraint)) => DeviceFormatRequest::HighestFramerate { + resolution: constraint.resolution.map(VideoResolution::from), + frame_format: constraint.frame_format.map(decode_device_frame_format).transpose()?, + }, + Some(Request::HighestResolution(constraint)) => DeviceFormatRequest::HighestResolution { + framerate_fps: constraint.framerate_fps, + frame_format: constraint.frame_format.map(decode_device_frame_format).transpose()?, + }, + }) +} + +#[cfg(feature = "capture-device")] +pub fn device_config_from_proto( + config: proto::DeviceVideoSourceConfig, +) -> FfiResult { + use proto::device_video_source_config::Device; + let device = match config.device { + None => DeviceSelector::Default, + Some(Device::DeviceIndex(index)) => DeviceSelector::Index(index as usize), + Some(Device::DeviceId(id)) => DeviceSelector::Id(id), + }; + let format = + config.format.map(device_format_request_from_proto).transpose()?.unwrap_or_default(); + Ok(DeviceVideoSourceConfig { device, format }) +} + +#[cfg(feature = "capture-device")] +pub fn device_info_to_proto(info: DeviceInfo) -> proto::CaptureDeviceInfo { + proto::CaptureDeviceInfo { + id: info.id, + name: info.name, + model_id: info.model_id, + manufacturer: info.manufacturer, + formats: info.formats.into_iter().filter_map(device_format_to_proto).collect(), + formats_complete: info.formats_complete, + } +} + #[cfg(feature = "capture-rtsp")] pub fn rtsp_config_from_proto( config: proto::RtspVideoSourceConfig, diff --git a/livekit-ffi/src/server/capture.rs b/livekit-ffi/src/server/capture.rs index fdbb7971e..d35c51699 100644 --- a/livekit-ffi/src/server/capture.rs +++ b/livekit-ffi/src/server/capture.rs @@ -28,6 +28,8 @@ use parking_lot::Mutex; #[cfg(feature = "capture-clock")] use livekit_capture::sources::clock::ClockVideoSource; +#[cfg(feature = "capture-device")] +use livekit_capture::sources::device::{self, DeviceVideoSource}; #[cfg(feature = "capture-gstreamer")] use livekit_capture::sources::gstreamer::GStreamerVideoSource; #[cfg(feature = "capture-pattern")] @@ -36,6 +38,8 @@ use livekit_capture::sources::pattern::PatternVideoSource; use livekit_capture::sources::rtsp::RtspVideoSource; use super::{video_source::FfiVideoSource, FfiHandle, FfiServer}; +#[cfg(feature = "capture-device")] +use crate::conversion::capture::{device_config_from_proto, device_info_to_proto}; #[cfg(feature = "capture-gstreamer")] use crate::conversion::capture::gstreamer_config_from_proto; #[cfg(feature = "capture-pattern")] @@ -140,6 +144,14 @@ async fn create_capture_source( let source: Box = Box::new(source); CapturePump::Pixel(PixelVideoPump::new(source)) } + #[cfg(feature = "capture-device")] + proto::new_capture_source_request::Config::Device(config) => { + let source = DeviceVideoSource::new(device_config_from_proto(config)?) + .await + .map_err(|err| FfiError::InvalidRequest(err.to_string().into()))?; + let source: Box = Box::new(source); + CapturePump::Pixel(PixelVideoPump::new(source)) + } #[cfg(feature = "capture-clock")] proto::new_capture_source_request::Config::Clock(config) => { let source = ClockVideoSource::new(config.into()) @@ -314,6 +326,28 @@ pub fn on_stop_capture( Ok(proto::StopCaptureResponse { error: None }) } +#[cfg(feature = "capture-device")] +pub fn on_list_capture_devices( + server: &'static FfiServer, + request: proto::ListCaptureDevicesRequest, +) -> FfiResult { + let async_id = server.resolve_async_id(request.request_async_id); + server.async_runtime.spawn(async move { + let message = match device::devices().await { + Ok(devices) => { + proto::list_capture_devices_callback::Message::Devices(proto::CaptureDeviceList { + devices: devices.into_iter().map(device_info_to_proto).collect(), + }) + } + Err(err) => proto::list_capture_devices_callback::Message::Error(err.to_string()), + }; + let _ = server.send_event(proto::ffi_event::Message::ListCaptureDevices( + proto::ListCaptureDevicesCallback { async_id, message: Some(message) }, + )); + }); + Ok(proto::ListCaptureDevicesResponse { async_id }) +} + #[cfg(all(test, feature = "capture-pattern"))] mod tests { use super::*; diff --git a/livekit-ffi/src/server/requests.rs b/livekit-ffi/src/server/requests.rs index 19b570358..204032c8d 100644 --- a/livekit-ffi/src/server/requests.rs +++ b/livekit-ffi/src/server/requests.rs @@ -1542,8 +1542,19 @@ pub fn handle_request( Request::StartCapture(req) => capture::on_start_capture(server, req)?.into(), #[cfg(feature = "capture")] Request::StopCapture(req) => capture::on_stop_capture(server, req)?.into(), + #[cfg(feature = "capture-device")] + Request::ListCaptureDevices(req) => capture::on_list_capture_devices(server, req)?.into(), + #[cfg(all(feature = "capture", not(feature = "capture-device")))] + Request::ListCaptureDevices(_) => { + return Err(FfiError::InvalidRequest( + "livekit-ffi was built without the 'capture-device' feature".into(), + )); + } #[cfg(not(feature = "capture"))] - Request::NewCaptureSource(_) | Request::StartCapture(_) | Request::StopCapture(_) => { + Request::NewCaptureSource(_) + | Request::StartCapture(_) + | Request::StopCapture(_) + | Request::ListCaptureDevices(_) => { return Err(FfiError::InvalidRequest( "livekit-ffi was built without the 'capture' feature".into(), )); From 7d3c2d6c39ef4d000c502549d1ea7a76d2ea4cff Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:21:26 -0700 Subject: [PATCH 5/8] Generate protobuf --- livekit-ffi-node-bindings/proto/ffi_pb.d.ts | 20 +++++++++++++++++++- livekit-ffi-node-bindings/proto/ffi_pb.js | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/livekit-ffi-node-bindings/proto/ffi_pb.d.ts b/livekit-ffi-node-bindings/proto/ffi_pb.d.ts index 93994e73f..df9de4b1f 100644 --- a/livekit-ffi-node-bindings/proto/ffi_pb.d.ts +++ b/livekit-ffi-node-bindings/proto/ffi_pb.d.ts @@ -28,7 +28,7 @@ import type { PerformRpcCallback, PerformRpcRequest, PerformRpcResponse, Registe import type { EnableRemoteTrackPublicationRequest, EnableRemoteTrackPublicationResponse, SetRemoteTrackPublicationQualityRequest, SetRemoteTrackPublicationQualityResponse, UpdateRemoteTrackPublicationDimensionRequest, UpdateRemoteTrackPublicationDimensionResponse } from "./track_publication_pb.js"; import type { ByteStreamOpenCallback, ByteStreamOpenRequest, ByteStreamOpenResponse, ByteStreamReaderEvent, ByteStreamReaderReadAllCallback, ByteStreamReaderReadAllRequest, ByteStreamReaderReadAllResponse, ByteStreamReaderReadIncrementalRequest, ByteStreamReaderReadIncrementalResponse, ByteStreamReaderWriteToFileCallback, ByteStreamReaderWriteToFileRequest, ByteStreamReaderWriteToFileResponse, ByteStreamWriterCloseCallback, ByteStreamWriterCloseRequest, ByteStreamWriterCloseResponse, ByteStreamWriterWriteCallback, ByteStreamWriterWriteRequest, ByteStreamWriterWriteResponse, StreamSendBytesCallback, StreamSendBytesRequest, StreamSendBytesResponse, StreamSendFileCallback, StreamSendFileRequest, StreamSendFileResponse, StreamSendTextCallback, StreamSendTextRequest, StreamSendTextResponse, TextStreamOpenCallback, TextStreamOpenRequest, TextStreamOpenResponse, TextStreamReaderEvent, TextStreamReaderReadAllCallback, TextStreamReaderReadAllRequest, TextStreamReaderReadAllResponse, TextStreamReaderReadIncrementalRequest, TextStreamReaderReadIncrementalResponse, TextStreamWriterCloseCallback, TextStreamWriterCloseRequest, TextStreamWriterCloseResponse, TextStreamWriterWriteCallback, TextStreamWriterWriteRequest, TextStreamWriterWriteResponse } from "./data_stream_pb.js"; import type { DataTrackStreamEvent, DataTrackStreamReadRequest, DataTrackStreamReadResponse, DefineSchemaCallback, DefineSchemaRequest, DefineSchemaResponse, GetSchemaCallback, GetSchemaRequest, GetSchemaResponse, LocalDataTrackIsPublishedRequest, LocalDataTrackIsPublishedResponse, LocalDataTrackTryPushRequest, LocalDataTrackTryPushResponse, LocalDataTrackUnpublishRequest, LocalDataTrackUnpublishResponse, PublishDataTrackCallback, PublishDataTrackRequest, PublishDataTrackResponse, RemoteDataTrackIsPublishedRequest, RemoteDataTrackIsPublishedResponse, RemoteDataTrackSetPipelineOptionsRequest, RemoteDataTrackSetPipelineOptionsResponse, SubscribeDataTrackRequest, SubscribeDataTrackResponse } from "./data_track_pb.js"; -import type { CaptureSourceEvent, NewCaptureSourceCallback, NewCaptureSourceRequest, NewCaptureSourceResponse, StartCaptureRequest, StartCaptureResponse, StopCaptureRequest, StopCaptureResponse } from "./capture_pb.js"; +import type { CaptureSourceEvent, ListCaptureDevicesCallback, ListCaptureDevicesRequest, ListCaptureDevicesResponse, NewCaptureSourceCallback, NewCaptureSourceRequest, NewCaptureSourceResponse, StartCaptureRequest, StartCaptureResponse, StopCaptureRequest, StopCaptureResponse } from "./capture_pb.js"; /** * @generated from enum livekit.proto.LogLevel @@ -646,6 +646,12 @@ export declare class FfiRequest extends Message { */ value: StopCaptureRequest; case: "stopCapture"; + } | { + /** + * @generated from field: livekit.proto.ListCaptureDevicesRequest list_capture_devices = 90; + */ + value: ListCaptureDevicesRequest; + case: "listCaptureDevices"; } | { case: undefined; value?: undefined }; constructor(data?: PartialMessage); @@ -1242,6 +1248,12 @@ export declare class FfiResponse extends Message { */ value: StopCaptureResponse; case: "stopCapture"; + } | { + /** + * @generated from field: livekit.proto.ListCaptureDevicesResponse list_capture_devices = 90; + */ + value: ListCaptureDevicesResponse; + case: "listCaptureDevices"; } | { case: undefined; value?: undefined }; constructor(data?: PartialMessage); @@ -1564,6 +1576,12 @@ export declare class FfiEvent extends Message { */ value: CaptureSourceEvent; case: "captureSourceEvent"; + } | { + /** + * @generated from field: livekit.proto.ListCaptureDevicesCallback list_capture_devices = 49; + */ + value: ListCaptureDevicesCallback; + case: "listCaptureDevices"; } | { case: undefined; value?: undefined }; constructor(data?: PartialMessage); diff --git a/livekit-ffi-node-bindings/proto/ffi_pb.js b/livekit-ffi-node-bindings/proto/ffi_pb.js index 2a50d61cc..6a1ca88e3 100644 --- a/livekit-ffi-node-bindings/proto/ffi_pb.js +++ b/livekit-ffi-node-bindings/proto/ffi_pb.js @@ -30,7 +30,7 @@ const { PerformRpcCallback, PerformRpcRequest, PerformRpcResponse, RegisterRpcMe const { EnableRemoteTrackPublicationRequest, EnableRemoteTrackPublicationResponse, SetRemoteTrackPublicationQualityRequest, SetRemoteTrackPublicationQualityResponse, UpdateRemoteTrackPublicationDimensionRequest, UpdateRemoteTrackPublicationDimensionResponse } = require("./track_publication_pb.js"); const { ByteStreamOpenCallback, ByteStreamOpenRequest, ByteStreamOpenResponse, ByteStreamReaderEvent, ByteStreamReaderReadAllCallback, ByteStreamReaderReadAllRequest, ByteStreamReaderReadAllResponse, ByteStreamReaderReadIncrementalRequest, ByteStreamReaderReadIncrementalResponse, ByteStreamReaderWriteToFileCallback, ByteStreamReaderWriteToFileRequest, ByteStreamReaderWriteToFileResponse, ByteStreamWriterCloseCallback, ByteStreamWriterCloseRequest, ByteStreamWriterCloseResponse, ByteStreamWriterWriteCallback, ByteStreamWriterWriteRequest, ByteStreamWriterWriteResponse, StreamSendBytesCallback, StreamSendBytesRequest, StreamSendBytesResponse, StreamSendFileCallback, StreamSendFileRequest, StreamSendFileResponse, StreamSendTextCallback, StreamSendTextRequest, StreamSendTextResponse, TextStreamOpenCallback, TextStreamOpenRequest, TextStreamOpenResponse, TextStreamReaderEvent, TextStreamReaderReadAllCallback, TextStreamReaderReadAllRequest, TextStreamReaderReadAllResponse, TextStreamReaderReadIncrementalRequest, TextStreamReaderReadIncrementalResponse, TextStreamWriterCloseCallback, TextStreamWriterCloseRequest, TextStreamWriterCloseResponse, TextStreamWriterWriteCallback, TextStreamWriterWriteRequest, TextStreamWriterWriteResponse } = require("./data_stream_pb.js"); const { DataTrackStreamEvent, DataTrackStreamReadRequest, DataTrackStreamReadResponse, DefineSchemaCallback, DefineSchemaRequest, DefineSchemaResponse, GetSchemaCallback, GetSchemaRequest, GetSchemaResponse, LocalDataTrackIsPublishedRequest, LocalDataTrackIsPublishedResponse, LocalDataTrackTryPushRequest, LocalDataTrackTryPushResponse, LocalDataTrackUnpublishRequest, LocalDataTrackUnpublishResponse, PublishDataTrackCallback, PublishDataTrackRequest, PublishDataTrackResponse, RemoteDataTrackIsPublishedRequest, RemoteDataTrackIsPublishedResponse, RemoteDataTrackSetPipelineOptionsRequest, RemoteDataTrackSetPipelineOptionsResponse, SubscribeDataTrackRequest, SubscribeDataTrackResponse } = require("./data_track_pb.js"); -const { CaptureSourceEvent, NewCaptureSourceCallback, NewCaptureSourceRequest, NewCaptureSourceResponse, StartCaptureRequest, StartCaptureResponse, StopCaptureRequest, StopCaptureResponse } = require("./capture_pb.js"); +const { CaptureSourceEvent, ListCaptureDevicesCallback, ListCaptureDevicesRequest, ListCaptureDevicesResponse, NewCaptureSourceCallback, NewCaptureSourceRequest, NewCaptureSourceResponse, StartCaptureRequest, StartCaptureResponse, StopCaptureRequest, StopCaptureResponse } = require("./capture_pb.js"); /** * @generated from enum livekit.proto.LogLevel From fbccc74579db03c87b30b2c103c9f22acc90592b Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:21:26 -0700 Subject: [PATCH 6/8] Changeset --- .changeset/capture-source-device.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/capture-source-device.md diff --git a/.changeset/capture-source-device.md b/.changeset/capture-source-device.md new file mode 100644 index 000000000..fb54e9e1e --- /dev/null +++ b/.changeset/capture-source-device.md @@ -0,0 +1,6 @@ +--- +livekit-capture: minor +livekit-ffi: minor +--- + +Add a capture source for camera devices. From 1634e275ef06254e42b588b5ead855126c35119f Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:35:47 +0000 Subject: [PATCH 7/8] generated protobuf --- .../proto/capture_pb.d.ts | 452 ++++++++++++++++++ livekit-ffi-node-bindings/proto/capture_pb.js | 168 +++++++ livekit-ffi-node-bindings/proto/ffi_pb.d.ts | 4 +- livekit-ffi-node-bindings/proto/ffi_pb.js | 3 + 4 files changed, 625 insertions(+), 2 deletions(-) diff --git a/livekit-ffi-node-bindings/proto/capture_pb.d.ts b/livekit-ffi-node-bindings/proto/capture_pb.d.ts index d08b41a2e..fe096b22f 100644 --- a/livekit-ffi-node-bindings/proto/capture_pb.d.ts +++ b/livekit-ffi-node-bindings/proto/capture_pb.d.ts @@ -61,6 +61,58 @@ export declare enum Pattern { LOGO = 1, } +/** + * Frame format delivered by a capture device. + * + * @generated from enum livekit.proto.DeviceFrameFormat + */ +export declare enum DeviceFrameFormat { + /** + * @generated from enum value: DEVICE_FRAME_FORMAT_I420 = 0; + */ + I420 = 0, + + /** + * @generated from enum value: DEVICE_FRAME_FORMAT_NV12 = 1; + */ + NV12 = 1, + + /** + * @generated from enum value: DEVICE_FRAME_FORMAT_BGRA = 2; + */ + BGRA = 2, + + /** + * @generated from enum value: DEVICE_FRAME_FORMAT_RGB24 = 3; + */ + RGB24 = 3, + + /** + * @generated from enum value: DEVICE_FRAME_FORMAT_BGR24 = 4; + */ + BGR24 = 4, + + /** + * @generated from enum value: DEVICE_FRAME_FORMAT_YUYV = 5; + */ + YUYV = 5, + + /** + * @generated from enum value: DEVICE_FRAME_FORMAT_UYVY = 6; + */ + UYVY = 6, + + /** + * @generated from enum value: DEVICE_FRAME_FORMAT_GREY = 7; + */ + GREY = 7, + + /** + * @generated from enum value: DEVICE_FRAME_FORMAT_MJPEG = 8; + */ + MJPEG = 8, +} + /** * Kind of media a capture source produces. * @@ -364,6 +416,400 @@ export declare class ClockVideoSourceConfig extends Message | undefined, b: ClockVideoSourceConfig | PlainMessage | undefined): boolean; } +/** + * Capture format offered by or requested from a device. + * + * @generated from message livekit.proto.DeviceFormat + */ +export declare class DeviceFormat extends Message { + /** + * Frame dimensions. + * + * @generated from field: required livekit.proto.VideoSourceResolution resolution = 1; + */ + resolution?: VideoSourceResolution; + + /** + * Frame rate in frames per second. + * + * @generated from field: required uint32 framerate_fps = 2; + */ + framerateFps?: number; + + /** + * Frame format. + * + * @generated from field: required livekit.proto.DeviceFrameFormat frame_format = 3; + */ + frameFormat?: DeviceFrameFormat; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto2; + static readonly typeName = "livekit.proto.DeviceFormat"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): DeviceFormat; + + static fromJson(jsonValue: JsonValue, options?: Partial): DeviceFormat; + + static fromJsonString(jsonString: string, options?: Partial): DeviceFormat; + + static equals(a: DeviceFormat | PlainMessage | undefined, b: DeviceFormat | PlainMessage | undefined): boolean; +} + +/** + * Format selection requested from a capture device. The device negotiates + * the delivered format; CaptureSourceInfo reports the outcome. + * + * @generated from message livekit.proto.DeviceFormatRequest + */ +export declare class DeviceFormatRequest extends Message { + /** + * The device's default format when unset. + * + * @generated from oneof livekit.proto.DeviceFormatRequest.request + */ + request: { + /** + * Require an exact format match. + * + * @generated from field: livekit.proto.DeviceFormat exact = 1; + */ + value: DeviceFormat; + case: "exact"; + } | { + /** + * Use the device's closest supported format. + * + * @generated from field: livekit.proto.DeviceFormat closest = 2; + */ + value: DeviceFormat; + case: "closest"; + } | { + /** + * @generated from field: livekit.proto.DeviceFormatRequest.HighestFramerate highest_framerate = 3; + */ + value: DeviceFormatRequest_HighestFramerate; + case: "highestFramerate"; + } | { + /** + * @generated from field: livekit.proto.DeviceFormatRequest.HighestResolution highest_resolution = 4; + */ + value: DeviceFormatRequest_HighestResolution; + case: "highestResolution"; + } | { case: undefined; value?: undefined }; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto2; + static readonly typeName = "livekit.proto.DeviceFormatRequest"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): DeviceFormatRequest; + + static fromJson(jsonValue: JsonValue, options?: Partial): DeviceFormatRequest; + + static fromJsonString(jsonString: string, options?: Partial): DeviceFormatRequest; + + static equals(a: DeviceFormatRequest | PlainMessage | undefined, b: DeviceFormatRequest | PlainMessage | undefined): boolean; +} + +/** + * Prefer the highest frame rate, optionally constrained. + * + * @generated from message livekit.proto.DeviceFormatRequest.HighestFramerate + */ +export declare class DeviceFormatRequest_HighestFramerate extends Message { + /** + * @generated from field: optional livekit.proto.VideoSourceResolution resolution = 1; + */ + resolution?: VideoSourceResolution; + + /** + * @generated from field: optional livekit.proto.DeviceFrameFormat frame_format = 2; + */ + frameFormat?: DeviceFrameFormat; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto2; + static readonly typeName = "livekit.proto.DeviceFormatRequest.HighestFramerate"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): DeviceFormatRequest_HighestFramerate; + + static fromJson(jsonValue: JsonValue, options?: Partial): DeviceFormatRequest_HighestFramerate; + + static fromJsonString(jsonString: string, options?: Partial): DeviceFormatRequest_HighestFramerate; + + static equals(a: DeviceFormatRequest_HighestFramerate | PlainMessage | undefined, b: DeviceFormatRequest_HighestFramerate | PlainMessage | undefined): boolean; +} + +/** + * Prefer the highest resolution, optionally constrained. + * + * @generated from message livekit.proto.DeviceFormatRequest.HighestResolution + */ +export declare class DeviceFormatRequest_HighestResolution extends Message { + /** + * @generated from field: optional uint32 framerate_fps = 1; + */ + framerateFps?: number; + + /** + * @generated from field: optional livekit.proto.DeviceFrameFormat frame_format = 2; + */ + frameFormat?: DeviceFrameFormat; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto2; + static readonly typeName = "livekit.proto.DeviceFormatRequest.HighestResolution"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): DeviceFormatRequest_HighestResolution; + + static fromJson(jsonValue: JsonValue, options?: Partial): DeviceFormatRequest_HighestResolution; + + static fromJsonString(jsonString: string, options?: Partial): DeviceFormatRequest_HighestResolution; + + static equals(a: DeviceFormatRequest_HighestResolution | PlainMessage | undefined, b: DeviceFormatRequest_HighestResolution | PlainMessage | undefined): boolean; +} + +/** + * Camera device capture using the platform's native capture stack. + * + * @generated from message livekit.proto.DeviceVideoSourceConfig + */ +export declare class DeviceVideoSourceConfig extends Message { + /** + * Device to capture from; the platform default device when unset. + * + * @generated from oneof livekit.proto.DeviceVideoSourceConfig.device + */ + device: { + /** + * Position in the platform enumeration order. + * + * @generated from field: uint32 device_index = 1; + */ + value: number; + case: "deviceIndex"; + } | { + /** + * Platform-stable identifier, as reported by CaptureDeviceInfo.id. + * + * @generated from field: string device_id = 2; + */ + value: string; + case: "deviceId"; + } | { case: undefined; value?: undefined }; + + /** + * Format requested from the device; the device default when unset. + * + * @generated from field: optional livekit.proto.DeviceFormatRequest format = 3; + */ + format?: DeviceFormatRequest; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto2; + static readonly typeName = "livekit.proto.DeviceVideoSourceConfig"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): DeviceVideoSourceConfig; + + static fromJson(jsonValue: JsonValue, options?: Partial): DeviceVideoSourceConfig; + + static fromJsonString(jsonString: string, options?: Partial): DeviceVideoSourceConfig; + + static equals(a: DeviceVideoSourceConfig | PlainMessage | undefined, b: DeviceVideoSourceConfig | PlainMessage | undefined): boolean; +} + +/** + * Video capture device discovered by ListCaptureDevicesRequest. + * + * @generated from message livekit.proto.CaptureDeviceInfo + */ +export declare class CaptureDeviceInfo extends Message { + /** + * Platform-stable device identifier. + * + * @generated from field: required string id = 1; + */ + id?: string; + + /** + * Human-readable device name. + * + * @generated from field: required string name = 2; + */ + name?: string; + + /** + * Device model identifier, when available. + * + * @generated from field: optional string model_id = 3; + */ + modelId?: string; + + /** + * Device manufacturer, when available. + * + * @generated from field: optional string manufacturer = 4; + */ + manufacturer?: string; + + /** + * Capture formats reported by the device. + * + * @generated from field: repeated livekit.proto.DeviceFormat formats = 5; + */ + formats: DeviceFormat[]; + + /** + * Whether `formats` is a complete list; some platforms do not enumerate + * formats up front. + * + * @generated from field: required bool formats_complete = 6; + */ + formatsComplete?: boolean; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto2; + static readonly typeName = "livekit.proto.CaptureDeviceInfo"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): CaptureDeviceInfo; + + static fromJson(jsonValue: JsonValue, options?: Partial): CaptureDeviceInfo; + + static fromJsonString(jsonString: string, options?: Partial): CaptureDeviceInfo; + + static equals(a: CaptureDeviceInfo | PlainMessage | undefined, b: CaptureDeviceInfo | PlainMessage | undefined): boolean; +} + +/** + * @generated from message livekit.proto.CaptureDeviceList + */ +export declare class CaptureDeviceList extends Message { + /** + * @generated from field: repeated livekit.proto.CaptureDeviceInfo devices = 1; + */ + devices: CaptureDeviceInfo[]; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto2; + static readonly typeName = "livekit.proto.CaptureDeviceList"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): CaptureDeviceList; + + static fromJson(jsonValue: JsonValue, options?: Partial): CaptureDeviceList; + + static fromJsonString(jsonString: string, options?: Partial): CaptureDeviceList; + + static equals(a: CaptureDeviceList | PlainMessage | undefined, b: CaptureDeviceList | PlainMessage | undefined): boolean; +} + +/** + * List the video capture devices available on this machine. + * + * Completes asynchronously with a ListCaptureDevicesCallback: enumeration + * queries the platform capture stack and may block briefly. + * + * @generated from message livekit.proto.ListCaptureDevicesRequest + */ +export declare class ListCaptureDevicesRequest extends Message { + /** + * @generated from field: optional uint64 request_async_id = 1; + */ + requestAsyncId?: bigint; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto2; + static readonly typeName = "livekit.proto.ListCaptureDevicesRequest"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): ListCaptureDevicesRequest; + + static fromJson(jsonValue: JsonValue, options?: Partial): ListCaptureDevicesRequest; + + static fromJsonString(jsonString: string, options?: Partial): ListCaptureDevicesRequest; + + static equals(a: ListCaptureDevicesRequest | PlainMessage | undefined, b: ListCaptureDevicesRequest | PlainMessage | undefined): boolean; +} + +/** + * @generated from message livekit.proto.ListCaptureDevicesResponse + */ +export declare class ListCaptureDevicesResponse extends Message { + /** + * @generated from field: required uint64 async_id = 1; + */ + asyncId?: bigint; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto2; + static readonly typeName = "livekit.proto.ListCaptureDevicesResponse"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): ListCaptureDevicesResponse; + + static fromJson(jsonValue: JsonValue, options?: Partial): ListCaptureDevicesResponse; + + static fromJsonString(jsonString: string, options?: Partial): ListCaptureDevicesResponse; + + static equals(a: ListCaptureDevicesResponse | PlainMessage | undefined, b: ListCaptureDevicesResponse | PlainMessage | undefined): boolean; +} + +/** + * @generated from message livekit.proto.ListCaptureDevicesCallback + */ +export declare class ListCaptureDevicesCallback extends Message { + /** + * @generated from field: required uint64 async_id = 1; + */ + asyncId?: bigint; + + /** + * @generated from oneof livekit.proto.ListCaptureDevicesCallback.message + */ + message: { + /** + * @generated from field: string error = 2; + */ + value: string; + case: "error"; + } | { + /** + * @generated from field: livekit.proto.CaptureDeviceList devices = 3; + */ + value: CaptureDeviceList; + case: "devices"; + } | { case: undefined; value?: undefined }; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto2; + static readonly typeName = "livekit.proto.ListCaptureDevicesCallback"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): ListCaptureDevicesCallback; + + static fromJson(jsonValue: JsonValue, options?: Partial): ListCaptureDevicesCallback; + + static fromJsonString(jsonString: string, options?: Partial): ListCaptureDevicesCallback; + + static equals(a: ListCaptureDevicesCallback | PlainMessage | undefined, b: ListCaptureDevicesCallback | PlainMessage | undefined): boolean; +} + /** * @generated from message livekit.proto.CaptureSourceInfo */ @@ -473,6 +919,12 @@ export declare class NewCaptureSourceRequest extends Message [ + { no: 1, name: "resolution", kind: "message", T: VideoSourceResolution, req: true }, + { no: 2, name: "framerate_fps", kind: "scalar", T: 13 /* ScalarType.UINT32 */, req: true }, + { no: 3, name: "frame_format", kind: "enum", T: proto2.getEnumType(DeviceFrameFormat), req: true }, + ], +); + +/** + * Format selection requested from a capture device. The device negotiates + * the delivered format; CaptureSourceInfo reports the outcome. + * + * @generated from message livekit.proto.DeviceFormatRequest + */ +const DeviceFormatRequest = /*@__PURE__*/ proto2.makeMessageType( + "livekit.proto.DeviceFormatRequest", + () => [ + { no: 1, name: "exact", kind: "message", T: DeviceFormat, oneof: "request" }, + { no: 2, name: "closest", kind: "message", T: DeviceFormat, oneof: "request" }, + { no: 3, name: "highest_framerate", kind: "message", T: DeviceFormatRequest_HighestFramerate, oneof: "request" }, + { no: 4, name: "highest_resolution", kind: "message", T: DeviceFormatRequest_HighestResolution, oneof: "request" }, + ], +); + +/** + * Prefer the highest frame rate, optionally constrained. + * + * @generated from message livekit.proto.DeviceFormatRequest.HighestFramerate + */ +const DeviceFormatRequest_HighestFramerate = /*@__PURE__*/ proto2.makeMessageType( + "livekit.proto.DeviceFormatRequest.HighestFramerate", + () => [ + { no: 1, name: "resolution", kind: "message", T: VideoSourceResolution, opt: true }, + { no: 2, name: "frame_format", kind: "enum", T: proto2.getEnumType(DeviceFrameFormat), opt: true }, + ], + {localName: "DeviceFormatRequest_HighestFramerate"}, +); + +/** + * Prefer the highest resolution, optionally constrained. + * + * @generated from message livekit.proto.DeviceFormatRequest.HighestResolution + */ +const DeviceFormatRequest_HighestResolution = /*@__PURE__*/ proto2.makeMessageType( + "livekit.proto.DeviceFormatRequest.HighestResolution", + () => [ + { no: 1, name: "framerate_fps", kind: "scalar", T: 13 /* ScalarType.UINT32 */, opt: true }, + { no: 2, name: "frame_format", kind: "enum", T: proto2.getEnumType(DeviceFrameFormat), opt: true }, + ], + {localName: "DeviceFormatRequest_HighestResolution"}, +); + +/** + * Camera device capture using the platform's native capture stack. + * + * @generated from message livekit.proto.DeviceVideoSourceConfig + */ +const DeviceVideoSourceConfig = /*@__PURE__*/ proto2.makeMessageType( + "livekit.proto.DeviceVideoSourceConfig", + () => [ + { no: 1, name: "device_index", kind: "scalar", T: 13 /* ScalarType.UINT32 */, oneof: "device" }, + { no: 2, name: "device_id", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "device" }, + { no: 3, name: "format", kind: "message", T: DeviceFormatRequest, opt: true }, + ], +); + +/** + * Video capture device discovered by ListCaptureDevicesRequest. + * + * @generated from message livekit.proto.CaptureDeviceInfo + */ +const CaptureDeviceInfo = /*@__PURE__*/ proto2.makeMessageType( + "livekit.proto.CaptureDeviceInfo", + () => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */, req: true }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */, req: true }, + { no: 3, name: "model_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + { no: 4, name: "manufacturer", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + { no: 5, name: "formats", kind: "message", T: DeviceFormat, repeated: true }, + { no: 6, name: "formats_complete", kind: "scalar", T: 8 /* ScalarType.BOOL */, req: true }, + ], +); + +/** + * @generated from message livekit.proto.CaptureDeviceList + */ +const CaptureDeviceList = /*@__PURE__*/ proto2.makeMessageType( + "livekit.proto.CaptureDeviceList", + () => [ + { no: 1, name: "devices", kind: "message", T: CaptureDeviceInfo, repeated: true }, + ], +); + +/** + * List the video capture devices available on this machine. + * + * Completes asynchronously with a ListCaptureDevicesCallback: enumeration + * queries the platform capture stack and may block briefly. + * + * @generated from message livekit.proto.ListCaptureDevicesRequest + */ +const ListCaptureDevicesRequest = /*@__PURE__*/ proto2.makeMessageType( + "livekit.proto.ListCaptureDevicesRequest", + () => [ + { no: 1, name: "request_async_id", kind: "scalar", T: 4 /* ScalarType.UINT64 */, opt: true }, + ], +); + +/** + * @generated from message livekit.proto.ListCaptureDevicesResponse + */ +const ListCaptureDevicesResponse = /*@__PURE__*/ proto2.makeMessageType( + "livekit.proto.ListCaptureDevicesResponse", + () => [ + { no: 1, name: "async_id", kind: "scalar", T: 4 /* ScalarType.UINT64 */, req: true }, + ], +); + +/** + * @generated from message livekit.proto.ListCaptureDevicesCallback + */ +const ListCaptureDevicesCallback = /*@__PURE__*/ proto2.makeMessageType( + "livekit.proto.ListCaptureDevicesCallback", + () => [ + { no: 1, name: "async_id", kind: "scalar", T: 4 /* ScalarType.UINT64 */, req: true }, + { no: 2, name: "error", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "message" }, + { no: 3, name: "devices", kind: "message", T: CaptureDeviceList, oneof: "message" }, + ], +); + /** * @generated from message livekit.proto.CaptureSourceInfo */ @@ -192,6 +348,7 @@ const NewCaptureSourceRequest = /*@__PURE__*/ proto2.makeMessageType( () => [ { no: 1, name: "gstreamer", kind: "message", T: GstreamerVideoSourceConfig, oneof: "config" }, { no: 2, name: "pattern", kind: "message", T: PatternVideoSourceConfig, oneof: "config" }, + { no: 4, name: "device", kind: "message", T: DeviceVideoSourceConfig, oneof: "config" }, { no: 5, name: "clock", kind: "message", T: ClockVideoSourceConfig, oneof: "config" }, { no: 6, name: "rtsp", kind: "message", T: RtspVideoSourceConfig, oneof: "config" }, { no: 3, name: "request_async_id", kind: "scalar", T: 4 /* ScalarType.UINT64 */, opt: true }, @@ -305,6 +462,7 @@ const CaptureSourceEvent = /*@__PURE__*/ proto2.makeMessageType( exports.GstreamerBitrateUnit = GstreamerBitrateUnit; exports.Pattern = Pattern; +exports.DeviceFrameFormat = DeviceFrameFormat; exports.CaptureSourceKind = CaptureSourceKind; exports.CaptureExit = CaptureExit; exports.GstreamerRateControl = GstreamerRateControl; @@ -312,6 +470,16 @@ exports.GstreamerVideoSourceConfig = GstreamerVideoSourceConfig; exports.RtspVideoSourceConfig = RtspVideoSourceConfig; exports.PatternVideoSourceConfig = PatternVideoSourceConfig; exports.ClockVideoSourceConfig = ClockVideoSourceConfig; +exports.DeviceFormat = DeviceFormat; +exports.DeviceFormatRequest = DeviceFormatRequest; +exports.DeviceFormatRequest_HighestFramerate = DeviceFormatRequest_HighestFramerate; +exports.DeviceFormatRequest_HighestResolution = DeviceFormatRequest_HighestResolution; +exports.DeviceVideoSourceConfig = DeviceVideoSourceConfig; +exports.CaptureDeviceInfo = CaptureDeviceInfo; +exports.CaptureDeviceList = CaptureDeviceList; +exports.ListCaptureDevicesRequest = ListCaptureDevicesRequest; +exports.ListCaptureDevicesResponse = ListCaptureDevicesResponse; +exports.ListCaptureDevicesCallback = ListCaptureDevicesCallback; exports.CaptureSourceInfo = CaptureSourceInfo; exports.OwnedCaptureSource = OwnedCaptureSource; exports.NewCaptureSourceRequest = NewCaptureSourceRequest; diff --git a/livekit-ffi-node-bindings/proto/ffi_pb.d.ts b/livekit-ffi-node-bindings/proto/ffi_pb.d.ts index df9de4b1f..8d246caae 100644 --- a/livekit-ffi-node-bindings/proto/ffi_pb.d.ts +++ b/livekit-ffi-node-bindings/proto/ffi_pb.d.ts @@ -648,7 +648,7 @@ export declare class FfiRequest extends Message { case: "stopCapture"; } | { /** - * @generated from field: livekit.proto.ListCaptureDevicesRequest list_capture_devices = 90; + * @generated from field: livekit.proto.ListCaptureDevicesRequest list_capture_devices = 92; */ value: ListCaptureDevicesRequest; case: "listCaptureDevices"; @@ -1250,7 +1250,7 @@ export declare class FfiResponse extends Message { case: "stopCapture"; } | { /** - * @generated from field: livekit.proto.ListCaptureDevicesResponse list_capture_devices = 90; + * @generated from field: livekit.proto.ListCaptureDevicesResponse list_capture_devices = 92; */ value: ListCaptureDevicesResponse; case: "listCaptureDevices"; diff --git a/livekit-ffi-node-bindings/proto/ffi_pb.js b/livekit-ffi-node-bindings/proto/ffi_pb.js index 6a1ca88e3..aa0cddbc5 100644 --- a/livekit-ffi-node-bindings/proto/ffi_pb.js +++ b/livekit-ffi-node-bindings/proto/ffi_pb.js @@ -145,6 +145,7 @@ const FfiRequest = /*@__PURE__*/ proto2.makeMessageType( { no: 91, name: "new_capture_source", kind: "message", T: NewCaptureSourceRequest, oneof: "message" }, { no: 89, name: "start_capture", kind: "message", T: StartCaptureRequest, oneof: "message" }, { no: 90, name: "stop_capture", kind: "message", T: StopCaptureRequest, oneof: "message" }, + { no: 92, name: "list_capture_devices", kind: "message", T: ListCaptureDevicesRequest, oneof: "message" }, ], ); @@ -245,6 +246,7 @@ const FfiResponse = /*@__PURE__*/ proto2.makeMessageType( { no: 91, name: "new_capture_source", kind: "message", T: NewCaptureSourceResponse, oneof: "message" }, { no: 89, name: "start_capture", kind: "message", T: StartCaptureResponse, oneof: "message" }, { no: 90, name: "stop_capture", kind: "message", T: StopCaptureResponse, oneof: "message" }, + { no: 92, name: "list_capture_devices", kind: "message", T: ListCaptureDevicesResponse, oneof: "message" }, ], ); @@ -305,6 +307,7 @@ const FfiEvent = /*@__PURE__*/ proto2.makeMessageType( { no: 46, name: "get_schema", kind: "message", T: GetSchemaCallback, oneof: "message" }, { no: 47, name: "new_capture_source", kind: "message", T: NewCaptureSourceCallback, oneof: "message" }, { no: 48, name: "capture_source_event", kind: "message", T: CaptureSourceEvent, oneof: "message" }, + { no: 49, name: "list_capture_devices", kind: "message", T: ListCaptureDevicesCallback, oneof: "message" }, ], ); From 65cefb37a0d54a8c350bda941b432fe1b491d18c Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:37:31 -0700 Subject: [PATCH 8/8] Use spawn_blocking directly --- livekit-capture/src/sources/device/mod.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/livekit-capture/src/sources/device/mod.rs b/livekit-capture/src/sources/device/mod.rs index e2eea9687..336a4bac5 100644 --- a/livekit-capture/src/sources/device/mod.rs +++ b/livekit-capture/src/sources/device/mod.rs @@ -248,9 +248,12 @@ impl DeviceInfo { /// /// Requires a running tokio runtime: enumeration runs on the tokio blocking /// pool. Use [`devices_blocking`] outside of async contexts. -#[cfg(feature = "tokio")] pub async fn devices() -> Result, SourceError> { - crate::utils::run_blocking(devices_blocking).await + match tokio::task::spawn_blocking(devices_blocking).await { + Ok(result) => result, + Err(err) if err.is_panic() => std::panic::resume_unwind(err.into_panic()), + Err(err) => Err(SourceError::new(err)), + } } /// Lists the video capture devices on this machine. @@ -300,9 +303,12 @@ impl DeviceVideoSource { /// /// Requires a running tokio runtime. Use /// [`DeviceVideoSource::new_blocking`] outside of async contexts. - #[cfg(feature = "tokio")] pub async fn new(config: DeviceVideoSourceConfig) -> Result { - crate::utils::run_blocking(move || Self::new_blocking(config)).await + match tokio::task::spawn_blocking(move || Self::new_blocking(config)).await { + Ok(result) => result, + Err(err) if err.is_panic() => std::panic::resume_unwind(err.into_panic()), + Err(err) => Err(SourceError::new(err)), + } } /// Opens the configured device and negotiates the capture format.