Skip to content

Commit 5ff1019

Browse files
committed
[+] feat: support capturing single camera and mixing it with screen
1 parent d4aef11 commit 5ff1019

24 files changed

+783
-169
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111

1212
### Features
1313
- Share screen via WebRTC
14-
- Single screen recording
1514
- Push stream via RTMP
15+
- Single screen recording
1616
- Single input device audio recording
17+
- Capturing a single camera
1718
- Desktop audio recording
1819
- Microphone noise reduction
1920
- Cursor tracking

README.zh-CN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@
1111

1212
### 功能
1313
- 屏幕共享(WebRTC)
14+
- 支持RTMP推流
1415
- 单个屏幕录制
1516
- 单个输入设备录音
17+
- 捕获单个摄像头
1618
- 桌面音频录制
1719
- 麦克风降噪
1820
- 光标跟随
1921
- 管理录制视频历史
2022
- 播放录制的历史视频
21-
- 支持RTMP推流
2223

2324
----
2425

lib/camera/examples/circle_demo.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use anyhow::Result;
22
use camera::{
33
Rgba,
4-
image_composition::{Shape, ShapeBase, ShapeCircle, mix_images},
4+
image_composition::{Shape, ShapeBase, ShapeCircle, MixPositionWithPadding, mix_images},
55
};
66
use image::RgbaImage;
77

@@ -27,7 +27,7 @@ fn main() -> Result<()> {
2727
let circle1 = ShapeCircle::default().with_radius(100).with_base(
2828
ShapeBase::default()
2929
.with_border_width(3)
30-
.with_pos((0.1, 0.1))
30+
.with_pos(MixPositionWithPadding::TopLeft((30, 30)))
3131
.with_zoom(1.0)
3232
.with_border_color(Rgba([255, 0, 0, 255]))
3333
.with_clip_pos((0.5, 0.5)),

lib/camera/examples/image_composition_demo.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use camera::{
22
Rgba,
33
image_composition::{
4-
Shape, ShapeBase, ShapeCircle, ShapeRectangle, mix_images, mix_images_rgb,
4+
MixPositionWithPadding, Shape, ShapeBase, ShapeCircle, ShapeRectangle, mix_images,
5+
mix_images_rgb,
56
},
67
};
78
use image::{RgbImage, RgbaImage};
@@ -29,7 +30,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
2930
log::info!("RGBA: Compositing with rectangle shape...");
3031
let rect = ShapeRectangle::default().with_size((300, 225)).with_base(
3132
ShapeBase::default()
32-
.with_pos((0.3, 0.3))
33+
.with_pos(MixPositionWithPadding::TopLeft((240, 180)))
3334
.with_border_width(10)
3435
.with_border_color(Rgba([255, 255, 255, 255])),
3536
);
@@ -44,7 +45,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
4445
log::info!("RGBA: Compositing with circle shape...");
4546
let circle = ShapeCircle::default().with_radius(150).with_base(
4647
ShapeBase::default()
47-
.with_pos((0.7, 0.6))
48+
.with_pos(MixPositionWithPadding::TopRight((210, 90)))
4849
.with_border_width(8)
4950
.with_border_color(Rgba([255, 255, 255, 255])),
5051
);
@@ -77,7 +78,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
7778
log::info!("RGB: Compositing with rectangle shape...");
7879
let rect_rgb = ShapeRectangle::default().with_size((300, 225)).with_base(
7980
ShapeBase::default()
80-
.with_pos((0.3, 0.3))
81+
.with_pos(MixPositionWithPadding::TopLeft((240, 180)))
8182
.with_border_width(10)
8283
.with_border_color(Rgba([255, 255, 255, 255])),
8384
);
@@ -92,7 +93,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
9293
log::info!("RGB: Compositing with circle shape...");
9394
let circle_rgb = ShapeCircle::default().with_radius(150).with_base(
9495
ShapeBase::default()
95-
.with_pos((0.7, 0.6))
96+
.with_pos(MixPositionWithPadding::TopRight((210, 90)))
9697
.with_border_width(8)
9798
.with_border_color(Rgba([255, 255, 255, 255])),
9899
);
@@ -107,7 +108,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
107108
log::info!("RGB: Compositing with plain rectangle (no border)...");
108109
let plain_rect_rgb = ShapeRectangle::default().with_size((250, 200)).with_base(
109110
ShapeBase::default()
110-
.with_pos((0.5, 0.5))
111+
.with_pos(MixPositionWithPadding::TopLeft((400, 300)))
111112
.with_border_width(0)
112113
.with_border_color(Rgba([0, 0, 0, 0])),
113114
);
@@ -119,6 +120,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
119120
result_plain_rgb.save("tmp/composition_rect_plain_rgb.png")?;
120121
log::info!("Saved RGB output to: tmp/composition_rect_plain_rgb.png");
121122

123+
log::info!("RGB: Compositing with plain circle (no border)...");
124+
let plain_circle_rgb = ShapeCircle::default().with_radius(100).with_base(
125+
ShapeBase::default()
126+
.with_pos(MixPositionWithPadding::TopLeft((200, 300)))
127+
.with_border_width(0)
128+
.with_border_color(Rgba([0, 0, 0, 0])),
129+
);
130+
let result_plain_rgb = mix_images_rgb(
131+
background_rgb.clone(),
132+
camera_image_rgb.clone(),
133+
Shape::Circle(plain_circle_rgb),
134+
)?;
135+
result_plain_rgb.save("tmp/composition_circle_plain_rgb.png")?;
136+
log::info!("Saved RGB output to: tmp/composition_circle_plain_rgb.png");
137+
122138
log::info!("");
123139
log::info!("All RGBA and RGB examples completed successfully!");
124140
Ok(())

lib/camera/examples/rectangle-demo.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use anyhow::Result;
22
use camera::{
33
Rgba,
4-
image_composition::{Shape, ShapeBase, ShapeRectangle, mix_images},
4+
image_composition::{Shape, ShapeBase, ShapeRectangle, MixPositionWithPadding, mix_images},
55
};
66
use image::RgbaImage;
77

@@ -22,7 +22,7 @@ fn main() -> Result<()> {
2222
let rect1 = ShapeRectangle::default().with_size((100, 50)).with_base(
2323
ShapeBase::default()
2424
.with_border_width(3)
25-
.with_pos((0.1, 0.1))
25+
.with_pos(MixPositionWithPadding::TopLeft((40, 30)))
2626
.with_zoom(2.0)
2727
.with_border_color(Rgba([255, 0, 0, 255]))
2828
.with_clip_pos((0.1, 0.1)),
@@ -36,7 +36,7 @@ fn main() -> Result<()> {
3636
let rect2 = ShapeRectangle::default().with_size((100, 50)).with_base(
3737
ShapeBase::default()
3838
.with_border_width(3)
39-
.with_pos((0.6, 0.6))
39+
.with_pos(MixPositionWithPadding::TopLeft((240, 180)))
4040
.with_zoom(2.0)
4141
.with_border_color(Rgba([255, 0, 0, 255]))
4242
.with_clip_pos((0.25, 0.25)),

lib/camera/src/image_composition.rs

Lines changed: 92 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,23 @@ enum BorderShape {
1515
Rectangle { width: u32, height: u32 },
1616
}
1717

18+
#[derive(Debug, Clone, Copy)]
19+
pub enum MixPositionWithPadding {
20+
TopLeft((u32, u32)), // top padding and left padding
21+
TopRight((u32, u32)), // top padding and right padding
22+
BottomLeft((u32, u32)), // bottom padding and left padding
23+
BottomRight((u32, u32)), // bottom padding and right padding
24+
}
25+
1826
#[derive(Debug, Clone, Copy, Derivative, Setters)]
1927
#[derivative(Default)]
2028
#[setters(prefix = "with_")]
2129
#[non_exhaustive]
2230
pub struct ShapeBase {
23-
/// Position in background_image [0, 1] range
24-
#[derivative(Default(value = "(0.5, 0.5)"))]
25-
pub pos: (f32, f32),
31+
/// Position of the shape on the background image
32+
/// Specifies padding from the background edges
33+
#[derivative(Default(value = "MixPositionWithPadding::BottomRight((32, 32))"))]
34+
pub pos: MixPositionWithPadding,
2635

2736
/// Border width in pixels
2837
#[derivative(Default(value = "2"))]
@@ -60,7 +69,6 @@ pub struct ShapeCircle {
6069
pub struct ShapeRectangle {
6170
pub base: ShapeBase,
6271

63-
/// Size (width, height) in pixels
6472
#[derivative(Default(value = "(100, 100)"))]
6573
pub size: (u32, u32),
6674
}
@@ -106,10 +114,30 @@ where
106114
let (bg_width, bg_height) = background.dimensions();
107115
let radius = circle.radius;
108116
let diameter = (radius * 2) as u32;
109-
let center_x =
110-
((circle.base.pos.0 * bg_width as f32) as u32).clamp(radius, bg_width as u32 - radius);
111-
let center_y =
112-
((circle.base.pos.1 * bg_height as f32) as u32).clamp(radius, bg_height as u32 - radius);
117+
118+
// Calculate center position based on MixPositionWithPadding
119+
let (center_x, center_y) = match circle.base.pos {
120+
MixPositionWithPadding::TopLeft((pad_top, pad_left)) => {
121+
let cx = (pad_left + radius).min(bg_width - radius);
122+
let cy = (pad_top + radius).min(bg_height - radius);
123+
(cx.max(radius), cy.max(radius))
124+
}
125+
MixPositionWithPadding::TopRight((pad_top, pad_right)) => {
126+
let cx = bg_width.saturating_sub(pad_right + radius).max(radius);
127+
let cy = (pad_top + radius).min(bg_height - radius);
128+
(cx.min(bg_width - radius), cy.max(radius))
129+
}
130+
MixPositionWithPadding::BottomLeft((pad_bottom, pad_left)) => {
131+
let cx = (pad_left + radius).min(bg_width - radius);
132+
let cy = bg_height.saturating_sub(pad_bottom + radius).max(radius);
133+
(cx.max(radius), cy.min(bg_height - radius))
134+
}
135+
MixPositionWithPadding::BottomRight((pad_bottom, pad_right)) => {
136+
let cx = bg_width.saturating_sub(pad_right + radius).max(radius);
137+
let cy = bg_height.saturating_sub(pad_bottom + radius).max(radius);
138+
(cx.min(bg_width - radius), cy.min(bg_height - radius))
139+
}
140+
};
113141

114142
let cropped_camera = crop_image_by_pixel_type(
115143
camera_image,
@@ -181,10 +209,31 @@ where
181209
P: Pixel<Subpixel = u8> + Copy,
182210
{
183211
let (bg_width, bg_height) = background.dimensions();
184-
let x = (rect.base.pos.0 * bg_width as f32) as u32;
185-
let y = (rect.base.pos.1 * bg_height as f32) as u32;
186212
let (width, height) = rect.size;
187213

214+
// Calculate top-left position based on MixPositionWithPadding
215+
let (x, y) = match rect.base.pos {
216+
MixPositionWithPadding::TopLeft((pad_top, pad_left)) => (
217+
pad_left.min(bg_width.saturating_sub(width)),
218+
pad_top.min(bg_height.saturating_sub(height)),
219+
),
220+
MixPositionWithPadding::TopRight((pad_top, pad_right)) => {
221+
let x = bg_width.saturating_sub(width + pad_right);
222+
let y = pad_top.min(bg_height.saturating_sub(height));
223+
(x, y)
224+
}
225+
MixPositionWithPadding::BottomLeft((pad_bottom, pad_left)) => {
226+
let x = pad_left.min(bg_width.saturating_sub(width));
227+
let y = bg_height.saturating_sub(height + pad_bottom);
228+
(x, y)
229+
}
230+
MixPositionWithPadding::BottomRight((pad_bottom, pad_right)) => {
231+
let x = bg_width.saturating_sub(width + pad_right);
232+
let y = bg_height.saturating_sub(height + pad_bottom);
233+
(x, y)
234+
}
235+
};
236+
188237
let cropped_camera = crop_image_by_pixel_type(
189238
camera_image,
190239
width,
@@ -193,15 +242,14 @@ where
193242
rect.base.clip_pos,
194243
)?;
195244

245+
let width = width.min(bg_width.saturating_sub(x));
246+
let height = height.min(bg_height.saturating_sub(y));
247+
196248
for cam_y in 0..height {
197249
for cam_x in 0..width {
198250
let bg_x = x + cam_x;
199251
let bg_y = y + cam_y;
200252

201-
if bg_x >= bg_width || bg_y >= bg_height {
202-
continue;
203-
}
204-
205253
let cam_pixel = cropped_camera.get_pixel(cam_x, cam_y);
206254
background.put_pixel(bg_x, bg_y, *cam_pixel);
207255
}
@@ -314,25 +362,40 @@ where
314362
// If the cropped image is smaller than target, pad with black
315363
if actual_crop_width < target_width || actual_crop_height < target_height {
316364
let mut result = ImageBuffer::new(target_width, target_height);
365+
366+
// Calculate offset position in target canvas based on clip_pos
367+
let offset_x =
368+
((target_width - actual_crop_width) as f32 * clip_pos.0.clamp(0.0, 1.0)).round() as u32;
369+
let offset_y = ((target_height - actual_crop_height) as f32 * clip_pos.1.clamp(0.0, 1.0))
370+
.round() as u32;
371+
317372
for y in 0..target_height {
318373
for x in 0..target_width {
319-
if x < actual_crop_width && y < actual_crop_height {
320-
let idx = (y * actual_crop_width + x) as usize * channel_count;
321-
let mut channels = [0u8; 4];
322-
for c in 0..channel_count {
323-
channels[c] = cropped_buffer[idx + c];
324-
}
325-
// Fill remaining channels with 255 (alpha) or 0
326-
for c in channel_count..4 {
327-
channels[c] = if c == 3 { 255 } else { 0 };
374+
// Check if current pixel is within the image area (offset-adjusted)
375+
let rel_x = x.checked_sub(offset_x);
376+
let rel_y = y.checked_sub(offset_y);
377+
378+
if let (Some(rx), Some(ry)) = (rel_x, rel_y) {
379+
if rx < actual_crop_width && ry < actual_crop_height {
380+
let idx = (ry * actual_crop_width + rx) as usize * channel_count;
381+
let mut channels = [0u8; 4];
382+
for c in 0..channel_count {
383+
channels[c] = cropped_buffer[idx + c];
384+
}
385+
// Fill remaining channels with 255 (alpha) or 0
386+
for c in channel_count..4 {
387+
channels[c] = if c == 3 { 255 } else { 0 };
388+
}
389+
let pixel = P::from_slice(&channels[..channel_count]);
390+
result.put_pixel(x, y, *pixel);
391+
continue;
328392
}
329-
let pixel = P::from_slice(&channels[..channel_count]);
330-
result.put_pixel(x, y, *pixel);
331-
} else {
332-
let black = [0u8; 4];
333-
let pixel = P::from_slice(&black[..channel_count]);
334-
result.put_pixel(x, y, *pixel);
335393
}
394+
395+
// Pad with black
396+
let black = [0u8; 4];
397+
let pixel = P::from_slice(&black[..channel_count]);
398+
result.put_pixel(x, y, *pixel);
336399
}
337400
}
338401
Ok(result)

lib/camera/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ pub mod image_composition;
55
pub use camera_client::{CameraClient, CameraConfig, PixelFormat};
66
pub use camera_info::{CameraInfo, query_available_cameras, query_camera_id, query_first_camera};
77
pub use image::{ImageBuffer, Rgb, Rgba, RgbaImage};
8-
pub use image_composition::{Shape, ShapeCircle, ShapeRectangle, mix_images, mix_images_rgb};
8+
pub use image_composition::{
9+
MixPositionWithPadding, Shape, ShapeBase, ShapeCircle, ShapeRectangle, mix_images,
10+
mix_images_rgb,
11+
};
912

1013
pub type CameraResult<T> = Result<T, CameraError>;
1114

lib/recorder/src/config.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,14 @@ impl PushStreamConfig {
194194
pub struct CameraMixConfig {
195195
pub enable: bool,
196196
pub camera_name: Option<String>,
197+
198+
pub fps: u32,
197199
pub width: u32,
198200
pub height: u32,
199-
pub fps: u32,
201+
pub mirror_horizontal: bool,
200202
pub pixel_format: camera::PixelFormat,
203+
201204
pub shape: Shape,
202-
pub mirror_horizontal: bool,
203205
}
204206

205207
impl Default for CameraMixConfig {

lib/recorder/src/recorder.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,10 +530,10 @@ impl RecordingSession {
530530

531531
let (camera_image_sender, camera_image_receiver) = bounded(5);
532532
let camera_config = CameraConfig::default()
533-
.with_pixel_format(camera::PixelFormat::RGB)
534533
.with_fps(self.config.camera_mix_config.fps)
535534
.with_width(self.config.camera_mix_config.width)
536535
.with_height(self.config.camera_mix_config.height)
536+
.with_pixel_format(self.config.camera_mix_config.pixel_format)
537537
.with_mirror_horizontal(self.config.camera_mix_config.mirror_horizontal);
538538

539539
let mut camera_client = CameraClient::new(camera_index, camera_config)?;

0 commit comments

Comments
 (0)