Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 55 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,63 @@ flatpak install camera-x86_64.flatpak

### From Source

#### Dependencies
#### Build Dependencies

- Rust (stable)
- GStreamer 1.0 with plugins (base, good, bad, ugly)
- libwayland
- libxkbcommon
- libinput
- libudev
- libseat
- GStreamer 1.0 development libraries
- libwayland-dev
- libxkbcommon-dev
- libinput-dev
- libudev-dev
- libseat-dev

#### Runtime Dependencies

| Dependency | Required | Purpose |
|------------|----------|---------|
| PipeWire | Yes | Camera access and audio |
| GStreamer 1.0 | Yes | Video processing pipeline |
| gst-plugins-base | Yes | Core GStreamer elements |
| gst-plugins-good | Yes | Video encoding (x264, vpx) |
| gst-plugins-bad | Yes | Additional encoders |
| gst-plugin-pipewire | Yes | PipeWire integration |
| GPU drivers | Recommended | Hardware-accelerated filters |
| v4l2loopback | Optional | Virtual camera for Discord, Chrome, etc. |

##### Fedora/RHEL

```bash
sudo dnf install pipewire gstreamer1 gstreamer1-plugins-base \
gstreamer1-plugins-good gstreamer1-plugins-bad-free \
pipewire-gstreamer mesa-dri-drivers

# Optional: Virtual camera for Discord/Chrome
sudo dnf install v4l2loopback
sudo modprobe v4l2loopback devices=1 video_nr=10 card_label="Camera Virtual" exclusive_caps=1
```

##### Ubuntu/Debian

```bash
sudo apt install pipewire gstreamer1.0-tools gstreamer1.0-plugins-base \
gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \
gstreamer1.0-pipewire mesa-utils

# Optional: Virtual camera for Discord/Chrome
sudo apt install v4l2loopback-dkms v4l2loopback-utils
sudo modprobe v4l2loopback devices=1 video_nr=10 card_label="Camera Virtual" exclusive_caps=1
```

##### Arch Linux

```bash
sudo pacman -S pipewire gstreamer gst-plugins-base gst-plugins-good \
gst-plugins-bad gst-plugin-pipewire mesa

# Optional: Virtual camera for Discord/Chrome
sudo pacman -S v4l2loopback-dkms
sudo modprobe v4l2loopback devices=1 video_nr=10 card_label="Camera Virtual" exclusive_caps=1
```

#### Build

Expand Down
4 changes: 4 additions & 0 deletions i18n/en/camera.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ mode-virtual = VIRTUAL
virtual-camera-title = Virtual camera (experimental)
virtual-camera-description = Stream your camera feed to other applications via a virtual camera device. Requires PipeWire.
virtual-camera-enable = Enable virtual camera
virtual-camera-output = Output device
virtual-camera-output-pipewire = PipeWire
virtual-camera-output-v4l2loopback = V4L2 Loopback
virtual-camera-output-unavailable = ({ $reason })
streaming-live = LIVE
virtual-camera-open-file = Open file
virtual-camera-file-filter-name = Images and Videos
Expand Down
55 changes: 52 additions & 3 deletions src/app/handlers/virtual_camera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use crate::app::state::{
AppModel, FileSource, FilterType, Message, VideoPlaybackCommand, VirtualCameraState,
};
use cosmic::cosmic_config::CosmicConfigEntry;
use cosmic::Task;
use std::sync::Arc;
use tracing::{debug, error, info, warn};
Expand Down Expand Up @@ -54,10 +55,12 @@ impl AppModel {
let height = format.height;
let filter_type = self.selected_filter;

let output_type = self.config.virtual_camera_output;
info!(
width,
height,
?filter_type,
?output_type,
"Starting virtual camera streaming from camera"
);

Expand All @@ -78,7 +81,7 @@ impl AppModel {
use crate::backends::virtual_camera::VirtualCameraManager;

// Create and start the virtual camera on this dedicated thread
let mut manager = VirtualCameraManager::new();
let mut manager = VirtualCameraManager::new(output_type);
manager.set_filter(filter_type);

let result = (|| {
Expand Down Expand Up @@ -170,11 +173,13 @@ impl AppModel {
self.stop_video_preview_playback();

let filter_type = self.selected_filter;
let output_type = self.config.virtual_camera_output;
let is_video = matches!(file_source, FileSource::Video(_));

info!(
?file_source,
?filter_type,
?output_type,
"Starting virtual camera from file source"
);

Expand Down Expand Up @@ -226,13 +231,15 @@ impl AppModel {
FileSource::Image(path) => Self::stream_image_to_virtual_camera(
&path,
filter_type,
output_type,
&mut filter_rx,
stop_rx,
preview_tx,
),
FileSource::Video(path) => Self::stream_video_to_virtual_camera(
&path,
filter_type,
output_type,
&mut filter_rx,
stop_rx,
preview_tx,
Expand Down Expand Up @@ -278,6 +285,7 @@ impl AppModel {
fn stream_image_to_virtual_camera(
path: &std::path::Path,
initial_filter: FilterType,
output_type: crate::constants::VirtualCameraOutput,
filter_rx: &mut tokio::sync::watch::Receiver<FilterType>,
mut stop_rx: tokio::sync::oneshot::Receiver<()>,
preview_tx: tokio::sync::mpsc::UnboundedSender<
Expand All @@ -294,7 +302,7 @@ impl AppModel {
let height = frame.height;

// Create and start virtual camera manager
let mut manager = VirtualCameraManager::new();
let mut manager = VirtualCameraManager::new(output_type);
manager.set_filter(initial_filter);
// File sources should not be mirrored - output exactly as the file content

Expand Down Expand Up @@ -358,6 +366,7 @@ impl AppModel {
fn stream_video_to_virtual_camera(
path: &std::path::Path,
initial_filter: FilterType,
output_type: crate::constants::VirtualCameraOutput,
filter_rx: &mut tokio::sync::watch::Receiver<FilterType>,
mut stop_rx: tokio::sync::oneshot::Receiver<()>,
preview_tx: tokio::sync::mpsc::UnboundedSender<
Expand All @@ -383,7 +392,7 @@ impl AppModel {
let (width, height) = decoder.dimensions();

// Create and start virtual camera manager
let mut manager = VirtualCameraManager::new();
let mut manager = VirtualCameraManager::new(output_type);
manager.set_filter(initial_filter);
// File sources should not be mirrored - output exactly as the file content

Expand Down Expand Up @@ -558,6 +567,46 @@ impl AppModel {
)
}

pub(crate) fn handle_select_virtual_camera_output(
&mut self,
index: usize,
) -> Task<cosmic::Action<Message>> {
use crate::constants::VirtualCameraOutput;

let Some(output_type) = VirtualCameraOutput::ALL.get(index).copied() else {
warn!(index, "Invalid virtual camera output index");
return Task::none();
};

// Check if the selected output is available
if !output_type.is_available() {
warn!(
?output_type,
reason = ?output_type.unavailable_reason(),
"Selected virtual camera output is not available"
);
// Don't change the selection if it's not available
return Task::none();
}

// Don't change if already selected
if self.config.virtual_camera_output == output_type {
return Task::none();
}

info!(?output_type, "Virtual camera output changed");
self.config.virtual_camera_output = output_type;

// Save the config change
if let Some(handler) = self.config_handler.as_ref() {
if let Err(err) = self.config.write_entry(handler) {
error!(?err, "Failed to save virtual camera output setting");
}
}

Task::none()
}

pub(crate) fn handle_virtual_camera_stopped(
&mut self,
result: Result<(), String>,
Expand Down
11 changes: 11 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,18 @@ impl cosmic::Application for AppModel {
.map(|p| p.display_name().to_string())
.collect(),
theme_dropdown_options: vec![fl!("match-desktop"), fl!("dark"), fl!("light")],
virtual_camera_output_dropdown_options: crate::constants::VirtualCameraOutput::ALL
.iter()
.map(|o| {
if o.is_available() {
o.display_name().to_string()
} else {
format!("{} (unavailable)", o.display_name())
}
})
.collect(),
device_info_visible: false,
bitrate_info_visible: false,
transition_state: crate::app::state::TransitionState::default(),
// QR detection enabled by default
qr_detection_enabled: true,
Expand Down
30 changes: 23 additions & 7 deletions src/app/settings/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,29 @@ impl AppModel {
);

// Virtual camera section
let virtual_camera_section = widget::settings::section().add(
widget::settings::item::builder(fl!("virtual-camera-title"))
.description(fl!("virtual-camera-description"))
.toggler(self.config.virtual_camera_enabled, |_| {
Message::ToggleVirtualCameraEnabled
}),
);
// Get current output index for dropdown
let current_output_index = crate::constants::VirtualCameraOutput::ALL
.iter()
.position(|o| *o == self.config.virtual_camera_output)
.unwrap_or(0);

let virtual_camera_section = widget::settings::section()
.add(
widget::settings::item::builder(fl!("virtual-camera-title"))
.description(fl!("virtual-camera-description"))
.toggler(self.config.virtual_camera_enabled, |_| {
Message::ToggleVirtualCameraEnabled
}),
)
.add(
widget::settings::item::builder(fl!("virtual-camera-output")).control(
widget::dropdown(
&self.virtual_camera_output_dropdown_options,
Some(current_output_index),
Message::SelectVirtualCameraOutput,
),
),
);

// Bug reports section
let bug_report_button = widget::button::standard(fl!("settings-report-bug"))
Expand Down
6 changes: 6 additions & 0 deletions src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,12 @@ pub struct AppModel {
pub bitrate_preset_dropdown_options: Vec<String>,
/// Theme dropdown options (Match Desktop, Dark, Light)
pub theme_dropdown_options: Vec<String>,
/// Virtual camera output dropdown options (with availability indicators)
pub virtual_camera_output_dropdown_options: Vec<String>,
/// Whether the device info panel is visible
pub device_info_visible: bool,
/// Whether the bitrate info matrix is visible
pub bitrate_info_visible: bool,

/// Transition state for camera/settings changes
pub transition_state: TransitionState,
Expand Down Expand Up @@ -908,6 +912,8 @@ pub enum Message {
SelectVideoEncoder(usize),
/// Toggle virtual camera feature enabled
ToggleVirtualCameraEnabled,
/// Select virtual camera output device type
SelectVirtualCameraOutput(usize),

// ===== System & Recovery =====
/// Camera backend recovery started
Expand Down
3 changes: 3 additions & 0 deletions src/app/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ impl AppModel {
Message::ClearTransitionBlur => self.handle_clear_transition_blur(),
Message::ToggleMirrorPreview => self.handle_toggle_mirror_preview(),
Message::ToggleVirtualCameraEnabled => self.handle_toggle_virtual_camera_enabled(),
Message::SelectVirtualCameraOutput(index) => {
self.handle_select_virtual_camera_output(index)
}

// ===== Format Selection =====
Message::SetMode(mode) => self.handle_set_mode(mode),
Expand Down
21 changes: 14 additions & 7 deletions src/backends/virtual_camera/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub use pipeline::VirtualCameraPipeline;

use crate::app::FilterType;
use crate::backends::camera::types::{BackendError, BackendResult, CameraFrame};
use crate::constants::VirtualCameraOutput;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, error, info, warn};
Expand All @@ -66,18 +67,21 @@ pub struct VirtualCameraManager {
gpu_available: bool,
/// Whether to horizontally flip output (for file sources, to counteract app auto-mirroring)
flip_horizontal: bool,
/// Output type (PipeWire or V4L2Loopback)
output_type: VirtualCameraOutput,
}

impl VirtualCameraManager {
/// Create a new virtual camera manager
pub fn new() -> Self {
/// Create a new virtual camera manager with the specified output type
pub fn new(output_type: VirtualCameraOutput) -> Self {
Self {
pipeline: None,
current_filter: FilterType::Standard,
streaming: false,
output_size: (1280, 720),
gpu_available: false,
flip_horizontal: false,
output_type,
}
}

Expand All @@ -92,7 +96,10 @@ impl VirtualCameraManager {

/// Start streaming to virtual camera
///
/// Creates a PipeWire virtual camera node that will be visible to other applications.
/// Creates a virtual camera output that will be visible to other applications:
/// - PipeWire: Creates a PipeWire virtual camera node
/// - V4L2Loopback: Writes to a /dev/video* loopback device
///
/// Uses GPU-accelerated filtering with software rendering fallback.
pub fn start(&mut self, width: u32, height: u32) -> BackendResult<()> {
if self.streaming {
Expand All @@ -101,10 +108,10 @@ impl VirtualCameraManager {
));
}

info!(width, height, "Starting virtual camera");
info!(width, height, ?self.output_type, "Starting virtual camera");

// Create and start the pipeline
let pipeline = VirtualCameraPipeline::new(width, height)?;
// Create and start the pipeline with the configured output type
let pipeline = VirtualCameraPipeline::new(width, height, self.output_type)?;
pipeline.start()?;

self.pipeline = Some(pipeline);
Expand Down Expand Up @@ -310,7 +317,7 @@ impl VirtualCameraManager {

impl Default for VirtualCameraManager {
fn default() -> Self {
Self::new()
Self::new(VirtualCameraOutput::default())
}
}

Expand Down
Loading