Skip to main content

wlr_capture/
capture.rs

1//! High-level capture: resolve a *source* (output, window, logical region) to a
2//! ready-to-use [`CapturedImage`], compositing across outputs when a region spans
3//! several (possibly mixed-scale) monitors.
4//!
5//! This sits above the [`wl`](crate::wl) engine and is shared by the capture tools
6//! (`wlr-shot` screenshots, `wlr-peek` colour/OCR). It is gated behind the `compose`
7//! feature because the multi-output path resamples with `image`; a headless recorder
8//! that only streams frames through [`sink`](crate::sink) doesn't pull it in.
9
10use crate::wl::{CapturedImage, Client, Frame, Output, Region};
11use anyhow::{Context, Result, bail};
12use std::time::Duration;
13
14/// Default time to wait for a one-shot frame from a source.
15pub const DEFAULT_BUDGET: Duration = Duration::from_secs(2);
16
17/// Extract CPU pixels from a one-shot frame. shm frames are already CPU pixels;
18/// dma-buf frames (only produced by the `gpu` build) are read back via an offscreen
19/// GL context. The readback context is built per call — fine for a one-shot tool;
20/// a streaming consumer would reuse one (see [`sink::pump`](crate::sink::pump)).
21pub fn frame_to_image(frame: Frame) -> Result<CapturedImage> {
22    match frame {
23        Frame::Shm(img) => Ok(img),
24        Frame::Dmabuf(d) => crate::gl::GpuReadback::new()
25            .and_then(|mut rb| rb.readback(d))
26            .context("readback GPU de la frame dma-buf"),
27    }
28}
29
30/// Capture a whole output: the named one, or the sole output if unnamed.
31pub fn capture_output(
32    client: &mut Client,
33    name: Option<&str>,
34    budget: Duration,
35) -> Result<CapturedImage> {
36    let outputs = client.outputs().to_vec();
37    let output = match name {
38        Some(n) => outputs
39            .iter()
40            .find(|o| o.name == n)
41            .with_context(|| format!("output '{n}' not found"))?,
42        None => match outputs.as_slice() {
43            [single] => single,
44            [] => bail!("no outputs available"),
45            many => {
46                let names: Vec<&str> = many.iter().map(|o| o.name.as_str()).collect();
47                bail!(
48                    "multiple outputs; specify -o NAME among: {}",
49                    names.join(", ")
50                );
51            }
52        },
53    };
54    frame_to_image(client.capture_output_once(output, budget)?)
55}
56
57/// Capture a window by its foreign-toplevel identifier.
58pub fn capture_window(client: &mut Client, id: &str, budget: Duration) -> Result<CapturedImage> {
59    let tl = client
60        .toplevels()
61        .iter()
62        .find(|t| t.identifier == id)
63        .cloned()
64        .with_context(|| format!("window '{id}' not found"))?;
65    frame_to_image(client.capture_toplevel_once(&tl, budget)?)
66}
67
68/// A captured output paired with its geometry, for compositing a region.
69pub struct OutputCapture {
70    /// The output, including its placement in the global logical space.
71    pub output: Output,
72    /// The captured pixels of that output.
73    pub image: CapturedImage,
74}
75
76/// Capture every output once — used as the interactive overlay's frozen backdrop,
77/// and then to composite the chosen region from the very same pixels.
78pub fn capture_all(client: &mut Client, budget: Duration) -> Result<Vec<OutputCapture>> {
79    let outputs = client.outputs().to_vec();
80    if outputs.is_empty() {
81        bail!("no outputs available");
82    }
83    let mut caps = Vec::with_capacity(outputs.len());
84    for output in outputs {
85        let image = frame_to_image(client.capture_output_once(&output, budget)?)?;
86        caps.push(OutputCapture { output, image });
87    }
88    Ok(caps)
89}
90
91/// Composite `region` from already-captured outputs. A region within a single
92/// output is returned at that output's native pixel resolution; a region spanning
93/// several (possibly mixed-scale) outputs is composited at logical resolution.
94pub fn composite(caps: &[OutputCapture], region: Region) -> Result<CapturedImage> {
95    if region.is_empty() {
96        bail!("empty region");
97    }
98    let covering: Vec<&OutputCapture> = caps
99        .iter()
100        .filter(|c| region.intersect(&c.output.logical_rect()).is_some())
101        .collect();
102
103    match covering.as_slice() {
104        [] => bail!("region covers no output"),
105        // Fast path: a single output → crop its native capture, no resampling.
106        [c] => {
107            let inter = region.intersect(&c.output.logical_rect()).unwrap();
108            Ok(c.image.crop(logical_to_physical(&c.output, inter)))
109        }
110        // Multi-output: composite at logical resolution.
111        many => {
112            let (dw, dh) = (region.w, region.h);
113            let mut dst = vec![0u8; (dw as usize) * (dh as usize) * 4];
114            for c in many {
115                let inter = region.intersect(&c.output.logical_rect()).unwrap();
116                let phys = logical_to_physical(&c.output, inter);
117                let logical = resize(c.image.crop(phys), inter.w, inter.h);
118                logical.blit_into(&mut dst, dw, dh, inter.x - region.x, inter.y - region.y);
119            }
120            Ok(CapturedImage {
121                width: dw,
122                height: dh,
123                rgba: dst,
124            })
125        }
126    }
127}
128
129/// Capture a logical region live (capture only the outputs it covers, then
130/// composite).
131pub fn capture_region(
132    client: &mut Client,
133    region: Region,
134    budget: Duration,
135) -> Result<CapturedImage> {
136    if region.is_empty() {
137        bail!("empty region");
138    }
139    let outputs: Vec<Output> = client
140        .outputs()
141        .iter()
142        .filter(|o| region.intersect(&o.logical_rect()).is_some())
143        .cloned()
144        .collect();
145    if outputs.is_empty() {
146        bail!("region covers no output");
147    }
148    let mut caps = Vec::with_capacity(outputs.len());
149    for output in outputs {
150        let image = frame_to_image(client.capture_output_once(&output, budget)?)?;
151        caps.push(OutputCapture { output, image });
152    }
153    composite(&caps, region)
154}
155
156/// The bounding box of every output, in logical coordinates (the whole desktop).
157pub fn whole_layout(client: &Client) -> Result<Region> {
158    let mut it = client.outputs().iter().map(Output::logical_rect);
159    let first = it.next().context("no outputs available")?;
160    let (mut x0, mut y0) = (first.x, first.y);
161    let (mut x1, mut y1) = (first.x + first.w as i32, first.y + first.h as i32);
162    for r in it {
163        x0 = x0.min(r.x);
164        y0 = y0.min(r.y);
165        x1 = x1.max(r.x + r.w as i32);
166        y1 = y1.max(r.y + r.h as i32);
167    }
168    Ok(Region {
169        x: x0,
170        y: y0,
171        w: (x1 - x0) as u32,
172        h: (y1 - y0) as u32,
173    })
174}
175
176/// Map a logical sub-rectangle of `output` to physical pixels within its capture
177/// (handles fractional scale via the physical/logical ratio).
178pub fn logical_to_physical(output: &Output, logical: Region) -> Region {
179    let (lw, lh) = output.logical_size();
180    let sx = output.phys_width as f64 / lw.max(1) as f64;
181    let sy = output.phys_height as f64 / lh.max(1) as f64;
182    let lr = output.logical_rect();
183    Region {
184        x: (((logical.x - lr.x) as f64) * sx).round() as i32,
185        y: (((logical.y - lr.y) as f64) * sy).round() as i32,
186        w: ((logical.w as f64) * sx).round() as u32,
187        h: ((logical.h as f64) * sy).round() as u32,
188    }
189}
190
191/// Resize a capture to `nw × nh` (Triangle filter); identity if already that size.
192pub fn resize(img: CapturedImage, nw: u32, nh: u32) -> CapturedImage {
193    if (img.width, img.height) == (nw, nh) || nw == 0 || nh == 0 {
194        return img;
195    }
196    let Some(buf) = image::RgbaImage::from_raw(img.width, img.height, img.rgba) else {
197        return CapturedImage {
198            width: 0,
199            height: 0,
200            rgba: Vec::new(),
201        };
202    };
203    let small = image::imageops::resize(&buf, nw, nh, image::imageops::FilterType::Triangle);
204    CapturedImage {
205        width: small.width(),
206        height: small.height(),
207        rgba: small.into_raw(),
208    }
209}
210
211/// Encode a captured image as PNG bytes (for tools that save a still without pulling in
212/// `image` themselves).
213pub fn encode_png(img: &CapturedImage) -> Result<Vec<u8>> {
214    let buf = image::RgbaImage::from_raw(img.width, img.height, img.rgba.clone())
215        .ok_or_else(|| anyhow::anyhow!("image dimensions don't match the buffer"))?;
216    let mut out = std::io::Cursor::new(Vec::new());
217    image::DynamicImage::ImageRgba8(buf)
218        .write_to(&mut out, image::ImageFormat::Png)
219        .context("PNG encode")?;
220    Ok(out.into_inner())
221}
222
223/// Parse a slurp-style geometry: `"X,Y WxH"` (X/Y may be negative).
224pub fn parse_geometry(s: &str) -> Result<Region> {
225    let err = || anyhow::anyhow!("invalid geometry '{s}' (expected 'X,Y WxH')");
226    let (pos, size) = s.trim().split_once(' ').ok_or_else(err)?;
227    let (x, y) = pos.split_once(',').ok_or_else(err)?;
228    let (w, h) = size.split_once(['x', 'X', '×']).ok_or_else(err)?;
229    Ok(Region {
230        x: x.trim().parse().map_err(|_| err())?,
231        y: y.trim().parse().map_err(|_| err())?,
232        w: w.trim().parse().map_err(|_| err())?,
233        h: h.trim().parse().map_err(|_| err())?,
234    })
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn parse_geometry_ok() {
243        let r = parse_geometry("10,20 300x400").unwrap();
244        assert_eq!((r.x, r.y, r.w, r.h), (10, 20, 300, 400));
245        let r = parse_geometry("-5,-6 7x8").unwrap();
246        assert_eq!((r.x, r.y, r.w, r.h), (-5, -6, 7, 8));
247    }
248
249    #[test]
250    fn parse_geometry_bad() {
251        assert!(parse_geometry("nonsense").is_err());
252        assert!(parse_geometry("1,2 3").is_err());
253    }
254}