use anyhow::{Context, Result};
use image::{RgbaImage, imageops};
use libwayshot::WayshotConnection;
use crate::{OutputId, Rect, Scene};
pub trait FrameCapture {
fn capture(&mut self, scene: &Scene) -> Result<DesktopFrame>;
}
pub struct WaylandCapture {
connection: WayshotConnection,
}
impl WaylandCapture {
pub fn connect() -> Result<Self> {
Ok(Self {
connection: WayshotConnection::new()?,
})
}
}
impl FrameCapture for WaylandCapture {
fn capture(&mut self, scene: &Scene) -> Result<DesktopFrame> {
let wayland_outputs = self.connection.get_all_outputs();
let outputs = scene
.outputs
.iter()
.map(|output| {
let wayland_output = wayland_outputs
.iter()
.find(|candidate| candidate.name == output.id.as_str())
.with_context(|| format!("Wayland did not advertise output {}", output.id))?;
let image = self
.connection
.screenshot_single_output(wayland_output, false)
.with_context(|| format!("failed to capture output {}", output.id))?
.into_rgba8();
Ok(OutputFrame {
output: output.id.clone(),
logical_geometry: output.logical_geometry,
image,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(DesktopFrame { outputs })
}
}
#[derive(Debug, Clone)]
pub struct OutputFrame {
pub output: OutputId,
pub logical_geometry: Rect,
pub image: RgbaImage,
}
impl OutputFrame {
#[must_use]
pub fn scale(&self) -> f64 {
self.scale_x().max(self.scale_y())
}
#[must_use]
pub fn scale_x(&self) -> f64 {
f64::from(self.image.width()) / self.logical_geometry.width()
}
#[must_use]
pub fn scale_y(&self) -> f64 {
f64::from(self.image.height()) / self.logical_geometry.height()
}
}
#[derive(Debug, Clone)]
pub struct DesktopFrame {
pub outputs: Vec<OutputFrame>,
}
impl DesktopFrame {
pub fn crop(&self, region: Rect) -> RgbaImage {
let frames = self
.outputs
.iter()
.filter_map(|frame| {
frame
.logical_geometry
.intersection(region)
.map(|intersection| (frame, intersection))
})
.collect::<Vec<_>>();
if let [(frame, intersection)] = frames.as_slice()
&& *intersection == region
{
let source = PixelRect::from_logical(
*intersection,
frame.logical_geometry,
frame.scale_x(),
frame.scale_y(),
);
return imageops::crop_imm(
&frame.image,
source.x,
source.y,
source.width,
source.height,
)
.to_image();
}
let scale = frames
.iter()
.map(|(frame, _)| frame.scale())
.reduce(f64::max)
.unwrap();
let mut result = RgbaImage::new(
snap_pixel_boundary(region.width() * scale).ceil() as u32,
snap_pixel_boundary(region.height() * scale).ceil() as u32,
);
for (frame, intersection) in frames {
let source = PixelRect::from_logical(
intersection,
frame.logical_geometry,
frame.scale_x(),
frame.scale_y(),
);
let source = imageops::crop_imm(
&frame.image,
source.x,
source.y,
source.width,
source.height,
)
.to_image();
let target = PixelRect::from_logical(intersection, region, scale, scale);
let source = if source.dimensions() == (target.width, target.height) {
source
} else {
imageops::resize(
&source,
target.width,
target.height,
imageops::FilterType::Lanczos3,
)
};
imageops::overlay(
&mut result,
&source,
i64::from(target.x),
i64::from(target.y),
);
}
result
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PixelRect {
x: u32,
y: u32,
width: u32,
height: u32,
}
fn snap_pixel_boundary(value: f64) -> f64 {
let nearest = value.round();
let tolerance = 8.0 * f64::EPSILON * value.abs().max(1.0);
if (value - nearest).abs() <= tolerance {
nearest
} else {
value
}
}
impl PixelRect {
fn from_logical(inner: Rect, outer: Rect, scale_x: f64, scale_y: f64) -> Self {
let left = snap_pixel_boundary((inner.left() - outer.left()) * scale_x).floor() as u32;
let top = snap_pixel_boundary((inner.top() - outer.top()) * scale_y).floor() as u32;
let right = snap_pixel_boundary((inner.right() - outer.left()) * scale_x).ceil() as u32;
let bottom = snap_pixel_boundary((inner.bottom() - outer.top()) * scale_y).ceil() as u32;
Self {
x: left,
y: top,
width: right - left,
height: bottom - top,
}
}
}
#[cfg(test)]
mod tests {
use image::Rgba;
use super::*;
fn solid_frame(output: &str, geometry: Rect, scale: f64, color: Rgba<u8>) -> OutputFrame {
let image = RgbaImage::from_pixel(
(geometry.width() * scale).ceil() as u32,
(geometry.height() * scale).ceil() as u32,
color,
);
OutputFrame {
output: OutputId::new(output),
logical_geometry: geometry,
image,
}
}
#[test]
fn crops_a_single_output_at_native_scale() {
let desktop = DesktopFrame {
outputs: vec![solid_frame(
"eDP-1",
Rect::new(0.0, 0.0, 100.0, 80.0),
2.0,
Rgba([10, 20, 30, 255]),
)],
};
let image = desktop.crop(Rect::new(10.0, 20.0, 30.0, 40.0));
assert_eq!(image.dimensions(), (60, 80));
assert_eq!(*image.get_pixel(0, 0), Rgba([10, 20, 30, 255]));
}
#[test]
fn mixed_dpi_crop_uses_the_highest_scale() {
let desktop = DesktopFrame {
outputs: vec![
solid_frame(
"left",
Rect::new(0.0, 0.0, 100.0, 100.0),
1.0,
Rgba([255, 0, 0, 255]),
),
solid_frame(
"right",
Rect::new(100.0, 0.0, 100.0, 100.0),
2.0,
Rgba([0, 0, 255, 255]),
),
],
};
let image = desktop.crop(Rect::new(50.0, 0.0, 100.0, 100.0));
assert_eq!(image.dimensions(), (200, 200));
assert_eq!(*image.get_pixel(25, 100), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(175, 100), Rgba([0, 0, 255, 255]));
}
#[test]
fn output_gaps_stay_transparent() {
let desktop = DesktopFrame {
outputs: vec![
solid_frame(
"left",
Rect::new(0.0, 0.0, 50.0, 50.0),
1.0,
Rgba([255, 0, 0, 255]),
),
solid_frame(
"right",
Rect::new(100.0, 0.0, 50.0, 50.0),
1.0,
Rgba([0, 0, 255, 255]),
),
],
};
let image = desktop.crop(Rect::new(0.0, 0.0, 150.0, 50.0));
assert_eq!(*image.get_pixel(75, 25), Rgba([0, 0, 0, 0]));
}
#[test]
fn source_coordinates_use_each_axis_pixel_density() {
let desktop = DesktopFrame {
outputs: vec![OutputFrame {
output: OutputId::new("fractional"),
logical_geometry: Rect::new(0.0, 0.0, 4.0, 3.0),
image: RgbaImage::from_fn(8, 9, |_, y| {
if y < 3 {
Rgba([255, 0, 0, 255])
} else {
Rgba([0, 0, 255, 255])
}
}),
}],
};
let image = desktop.crop(Rect::new(0.0, 1.0, 4.0, 1.0));
assert!(image.pixels().all(|pixel| *pixel == Rgba([0, 0, 255, 255])));
}
#[test]
fn fractional_origin_crop_stays_on_the_native_pixel_grid() {
let desktop = DesktopFrame {
outputs: vec![OutputFrame {
output: OutputId::new("fractional"),
logical_geometry: Rect::new(0.0, 0.0, 8.0, 8.0),
image: RgbaImage::from_fn(10, 10, |x, _| Rgba([x as u8, 0, 0, 255])),
}],
};
let image = desktop.crop(Rect::new(0.4, 0.4, 4.0, 4.0));
assert_eq!(image.dimensions(), (6, 6));
assert_eq!(*image.get_pixel(0, 0), Rgba([0, 0, 0, 255]));
assert_eq!(*image.get_pixel(5, 0), Rgba([5, 0, 0, 255]));
}
#[test]
fn pixel_aligned_crop_ignores_floating_point_noise() {
let desktop = DesktopFrame {
outputs: vec![OutputFrame {
output: OutputId::new("fractional"),
logical_geometry: Rect::new(0.0, 0.0, 1.0, 1028.5714285714287),
image: RgbaImage::from_fn(1, 1800, |_, y| Rgba([y as u8, 0, 0, 255])),
}],
};
let image = desktop.crop(Rect::new(0.0, 46.285714285714285, 1.0, 974.8571428571428));
assert_eq!(image.dimensions(), (1, 1706));
assert_eq!(*image.get_pixel(0, 0), Rgba([81, 0, 0, 255]));
}
}