Skip to main content

computer_use/
image_util.rs

1//! Encode a screenshot, fit it to a max width or canvas, and remap pixels.
2
3use std::io::Cursor;
4
5use base64::Engine;
6use image::codecs::jpeg::JpegEncoder;
7use image::codecs::png::PngEncoder;
8use image::{ImageEncoder, Rgba, RgbaImage};
9use serde::{Deserialize, Serialize};
10
11use crate::error::ComputerError;
12use crate::types::{normalize_scale, Frame, ImageFormat};
13
14const JPEG_QUALITY: u8 = 80;
15const MAX_EDGE: u32 = 32_768;
16const MAX_BYTES: u64 = 512 * 1024 * 1024;
17
18/// Default screenshot width after P4. `0` means do not scale.
19pub const DEFAULT_SCREENSHOT_MAX_WIDTH: u32 = 1440;
20
21const IMAGE_TO_SCREEN: &str = "screen = origin + (image - pad) * logical_size / image_content_size";
22const SCREEN_TO_IMAGE: &str = "image = pad + (screen - origin) * image_content_size / logical_size";
23
24/// How a ScreenCaptureKit failure should be treated. Pure; no system call.
25///
26/// Compiled on macOS and in tests. Other platforms do not call it.
27#[cfg(any(test, target_os = "macos"))]
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub(crate) enum CaptureFault {
30    /// Screen Recording permission or a TCC denial.
31    Permission,
32    /// The CG window id was absent from shareable content.
33    MissingWindow,
34    /// Any other capture failure. Callers may fall back.
35    Other,
36}
37
38/// Classify a fake or real ScreenCaptureKit error without calling the framework.
39#[cfg(any(test, target_os = "macos"))]
40pub(crate) fn classify_sck_error(domain: &str, code: i64, message: &str) -> CaptureFault {
41    let domain = domain.to_ascii_lowercase();
42    let message = message.to_ascii_lowercase();
43    if code == -3801 || code == -3803 {
44        return CaptureFault::Permission;
45    }
46    if domain.contains("tcc")
47        || message.contains("screen recording")
48        || message.contains("declined")
49        || message.contains("not authorized")
50    {
51        return CaptureFault::Permission;
52    }
53    if message.contains("window not found") {
54        return CaptureFault::MissingWindow;
55    }
56    CaptureFault::Other
57}
58
59/// True when neither shareable-content query contained the CG window id.
60#[cfg(test)]
61fn window_absent(on_screen_found: bool, off_screen_found: bool) -> bool {
62    !on_screen_found && !off_screen_found
63}
64
65/// Facts a capture path can observe. Tests pass fakes; macOS fills them from CoreGraphics.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub(crate) struct DisplayFacts {
68    /// `CGSSessionScreenIsLocked` was true.
69    pub screen_locked: bool,
70    /// An active display reported `CGDisplayIsAsleep`.
71    pub display_asleep: bool,
72    /// Displays reported by the capture API. `None` means the caller did not ask.
73    pub display_count: Option<usize>,
74    /// Windows ScreenCaptureKit returned. `None` means the caller did not ask.
75    pub shareable_window_count: Option<usize>,
76}
77
78/// Why capture cannot run, checked before `window_not_found`.
79pub(crate) fn display_block_reason(facts: DisplayFacts) -> Option<&'static str> {
80    if facts.screen_locked {
81        return Some("the screen is locked");
82    }
83    if facts.display_asleep {
84        return Some("the display is asleep");
85    }
86    if facts.display_count == Some(0) {
87        return Some("no display available");
88    }
89    if facts.shareable_window_count == Some(0) {
90        return Some("ScreenCaptureKit returned no windows");
91    }
92    None
93}
94
95/// `display_unavailable` when the screen cannot be captured, otherwise `window_not_found`.
96///
97/// Production callers are macOS ScreenCaptureKit. Tests on every OS pass fake facts.
98#[cfg(any(test, target_os = "macos"))]
99pub(crate) fn error_for_missing_window(facts: DisplayFacts, window_id: u32) -> ComputerError {
100    if let Some(reason) = display_block_reason(facts) {
101        return ComputerError::DisplayUnavailable(reason.into());
102    }
103    ComputerError::WindowNotFound(format!(
104        "cg window {window_id} is not in SCShareableContent"
105    ))
106}
107
108/// Integer point stored on the screenshot map.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110pub struct MapPoint {
111    /// Horizontal component.
112    pub x: i32,
113    /// Vertical component.
114    pub y: i32,
115}
116
117/// The two formulas from the screenshot-map contract.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct MapFormulas {
120    /// Image pixels to screen points.
121    pub image_to_screen: String,
122    /// Screen points to image pixels.
123    pub screen_to_image: String,
124}
125
126/// Map attached to every screenshot and stored in the session cache.
127///
128/// `content_width` and `content_height` are the drawn image inside any
129/// letterbox. `pad` is only the top-left inset, so an odd leftover pixel
130/// stays on the right or bottom and the formula stays exact.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct ScreenshotMap {
133    /// Output pixel width, including letterbox padding.
134    pub width: u32,
135    /// Output pixel height, including letterbox padding.
136    pub height: u32,
137    /// Capture backing scale. This is not the downscale ratio.
138    pub scale: f64,
139    /// Source width in screen points.
140    pub logical_width: u32,
141    /// Source height in screen points.
142    pub logical_height: u32,
143    /// Top-left of the captured area in screen points.
144    pub origin: MapPoint,
145    /// `width`, `none`, or `canvas`.
146    pub fit: String,
147    /// Top-left letterbox inset in output pixels.
148    pub pad: MapPoint,
149    /// Drawn width inside the letterbox.
150    pub content_width: u32,
151    /// Drawn height inside the letterbox.
152    pub content_height: u32,
153    /// Remap formulas.
154    pub map: MapFormulas,
155}
156
157/// Encoded screenshot bytes and the final pixel size.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct EncodedImage {
160    /// File bytes.
161    pub bytes: Vec<u8>,
162    /// Pixel width after an optional max-width resize.
163    pub width: u32,
164    /// Pixel height after an optional max-width resize.
165    pub height: u32,
166}
167
168/// Image plus the map that describes it.
169#[derive(Debug, Clone, PartialEq)]
170pub struct PreparedShot {
171    /// Pixels written to disk.
172    pub image: RgbaImage,
173    /// Remap metadata.
174    pub map: ScreenshotMap,
175}
176
177/// One window or sheet placed into a union image.
178#[cfg(any(test, target_os = "macos"))]
179#[derive(Debug, Clone)]
180pub(crate) struct Layer {
181    /// Captured pixels.
182    pub image: RgbaImage,
183    /// Left edge in screen points.
184    pub origin_x: i32,
185    /// Top edge in screen points.
186    pub origin_y: i32,
187    /// Width in screen points.
188    pub logical_width: u32,
189    /// Height in screen points.
190    pub logical_height: u32,
191}
192
193/// Union of composited layers.
194#[cfg(any(test, target_os = "macos"))]
195#[derive(Debug, Clone)]
196pub(crate) struct Composite {
197    /// Sheet drawn above the parent.
198    pub image: RgbaImage,
199    /// Union left edge in screen points.
200    pub origin_x: i32,
201    /// Union top edge in screen points.
202    pub origin_y: i32,
203    /// Union width in screen points.
204    pub logical_width: u32,
205    /// Union height in screen points.
206    pub logical_height: u32,
207}
208
209/// Resize to `max_width` when the frame is wider, then encode.
210///
211/// `None` and `Some(0)` both keep the source width.
212pub fn encode_image(
213    frame: &Frame,
214    format: ImageFormat,
215    max_width: Option<u32>,
216) -> Result<EncodedImage, ComputerError> {
217    let image = limit_width(frame.image(), max_width.unwrap_or(0))?;
218    encode_rgba(&image, format)
219}
220
221/// Encode pixels that [`prepare_shot`] already fitted.
222pub fn encode_rgba(image: &RgbaImage, format: ImageFormat) -> Result<EncodedImage, ComputerError> {
223    let bytes = match format {
224        ImageFormat::Png => encode_png(image)?,
225        ImageFormat::Jpeg => encode_jpeg(image)?,
226    };
227    Ok(EncodedImage {
228        width: image.width(),
229        height: image.height(),
230        bytes,
231    })
232}
233
234/// Standard base64, without line wraps.
235pub fn encode_base64(bytes: &[u8]) -> String {
236    base64::engine::general_purpose::STANDARD.encode(bytes)
237}
238
239/// Parse `1440x900`, `1440X900`, or `1440×900`.
240pub fn parse_canvas(text: &str) -> Result<(u32, u32), ComputerError> {
241    let normalized = text.trim().replace(['×', 'X'], "x");
242    let Some((width, height)) = normalized.split_once('x') else {
243        return Err(ComputerError::InvalidArguments(format!(
244            "canvas must be WxH (got {text:?})"
245        )));
246    };
247    let width = parse_canvas_edge(width, "width")?;
248    let height = parse_canvas_edge(height, "height")?;
249    Ok((width, height))
250}
251
252/// Geometry and fit policy for one prepared screenshot.
253pub struct ShotFit {
254    /// Capture backing scale.
255    pub scale: f64,
256    /// Left edge of the captured area in screen points.
257    pub origin_x: i32,
258    /// Top edge of the captured area in screen points.
259    pub origin_y: i32,
260    /// Source width in screen points.
261    pub logical_width: u32,
262    /// Source height in screen points.
263    pub logical_height: u32,
264    /// Maximum output width. `0` does not scale.
265    pub max_width: u32,
266    /// Letterbox canvas. When set, it wins over `max_width`.
267    pub canvas: Option<(u32, u32)>,
268}
269
270/// Fit `image` and build the screenshot map.
271///
272/// `max_width == 0` does not scale. A canvas letterboxes and wins over
273/// `max_width`. The image is never scaled up.
274pub fn prepare_shot(image: &RgbaImage, fit: ShotFit) -> Result<PreparedShot, ComputerError> {
275    let ShotFit {
276        scale,
277        origin_x,
278        origin_y,
279        logical_width,
280        logical_height,
281        max_width,
282        canvas,
283    } = fit;
284    let scale = normalize_scale(scale);
285    let logical_width = logical_width.max(1);
286    let logical_height = logical_height.max(1);
287    let (fitted, fit, pad_x, pad_y, content_width, content_height) = match canvas {
288        Some((canvas_w, canvas_h)) => {
289            checked_size(canvas_w, canvas_h)?;
290            let (content_w, content_h) =
291                fit_inside(image.width(), image.height(), canvas_w, canvas_h);
292            let content = resize_to(image, content_w, content_h);
293            let pad_x = (canvas_w - content.width()) / 2;
294            let pad_y = (canvas_h - content.height()) / 2;
295            let mut canvas_image = RgbaImage::from_pixel(canvas_w, canvas_h, Rgba([0, 0, 0, 255]));
296            image::imageops::overlay(
297                &mut canvas_image,
298                &content,
299                i64::from(pad_x),
300                i64::from(pad_y),
301            );
302            (
303                canvas_image,
304                "canvas",
305                pad_x,
306                pad_y,
307                content.width(),
308                content.height(),
309            )
310        }
311        None => {
312            let fitted = limit_width(image, max_width)?;
313            let fit = if max_width == 0 { "none" } else { "width" };
314            (fitted, fit, 0, 0, 0, 0)
315        }
316    };
317    let content_width = if canvas.is_some() {
318        content_width
319    } else {
320        fitted.width()
321    };
322    let content_height = if canvas.is_some() {
323        content_height
324    } else {
325        fitted.height()
326    };
327    checked_size(fitted.width(), fitted.height())?;
328    Ok(PreparedShot {
329        map: ScreenshotMap {
330            width: fitted.width(),
331            height: fitted.height(),
332            scale,
333            logical_width,
334            logical_height,
335            origin: MapPoint {
336                x: origin_x,
337                y: origin_y,
338            },
339            fit: fit.to_string(),
340            pad: MapPoint {
341                x: i32::try_from(pad_x).unwrap_or(i32::MAX),
342                y: i32::try_from(pad_y).unwrap_or(i32::MAX),
343            },
344            content_width,
345            content_height,
346            map: MapFormulas {
347                image_to_screen: IMAGE_TO_SCREEN.to_string(),
348                screen_to_image: SCREEN_TO_IMAGE.to_string(),
349            },
350        },
351        image: fitted,
352    })
353}
354
355/// Image pixel to screen point.
356pub fn image_to_screen(map: &ScreenshotMap, image_x: f64, image_y: f64) -> (f64, f64) {
357    let content_w = f64::from(map.content_width.max(1));
358    let content_h = f64::from(map.content_height.max(1));
359    let logical_w = f64::from(map.logical_width.max(1));
360    let logical_h = f64::from(map.logical_height.max(1));
361    let screen_x = map.origin.x as f64 + (image_x - map.pad.x as f64) * logical_w / content_w;
362    let screen_y = map.origin.y as f64 + (image_y - map.pad.y as f64) * logical_h / content_h;
363    (screen_x, screen_y)
364}
365
366/// Screen point to image pixel.
367pub fn screen_to_image(map: &ScreenshotMap, screen_x: f64, screen_y: f64) -> (f64, f64) {
368    let content_w = f64::from(map.content_width.max(1));
369    let content_h = f64::from(map.content_height.max(1));
370    let logical_w = f64::from(map.logical_width.max(1));
371    let logical_h = f64::from(map.logical_height.max(1));
372    let image_x = map.pad.x as f64 + (screen_x - map.origin.x as f64) * content_w / logical_w;
373    let image_y = map.pad.y as f64 + (screen_y - map.origin.y as f64) * content_h / logical_h;
374    (image_x, image_y)
375}
376
377/// Map a screen-point rectangle into image pixels of `map`.
378///
379/// Shared by every backend that draws platform frames over a capture: UIA
380/// bounding rectangles on Windows, `AXFrame` on macOS. Both are in screen
381/// points, so both need this exact conversion before a frame can be compared
382/// with an OCR block or drawn as a mark.
383///
384/// Returns `None` when the rect falls entirely off the canvas. The result is
385/// `(x, y, width, height)` with a minimum of one pixel per side.
386#[cfg_attr(all(not(target_os = "windows"), not(test)), allow(dead_code))]
387pub fn screen_rect_to_image(
388    map: &ScreenshotMap,
389    x: f64,
390    y: f64,
391    width: f64,
392    height: f64,
393) -> Option<(i32, i32, u32, u32)> {
394    let content_w = f64::from(map.content_width.max(1));
395    let content_h = f64::from(map.content_height.max(1));
396    let pad_x = map.pad.x as f64;
397    let pad_y = map.pad.y as f64;
398    let logical_w = f64::from(map.logical_width.max(1));
399    let logical_h = f64::from(map.logical_height.max(1));
400    let ix = pad_x + (x - map.origin.x as f64) * content_w / logical_w;
401    let iy = pad_y + (y - map.origin.y as f64) * content_h / logical_h;
402    let iw = (width * content_w / logical_w).max(1.0);
403    let ih = (height * content_h / logical_h).max(1.0);
404    let i = ix.round() as i32;
405    let j = iy.round() as i32;
406    let w = iw.round().max(1.0) as u32;
407    let h = ih.round().max(1.0) as u32;
408    if i < 0 || j < 0 {
409        return None;
410    }
411    if f64::from(i) + f64::from(w) > content_w + pad_x + 1.0
412        || f64::from(j) + f64::from(h) > content_h + pad_y + 1.0
413    {
414        return None;
415    }
416    Some((i, j, w, h))
417}
418
419#[cfg_attr(all(not(target_os = "windows"), not(test)), allow(dead_code))]
420/// DPI assumed when a platform reports none for a window.
421pub const DEFAULT_DPI: u32 = 96;
422
423/// Where a per-window capture sits in screen space, plus its backing scale.
424///
425/// Windows (P12b) needs this because its capture buffer is in device pixels
426/// while every coordinate the crate emits — `GetWindowRect` bounds, UIA
427/// bounding rectangles, `SetCursorPos` — is in the process's screen-point
428/// space. `logical_*` is therefore the window *rect*, and `scale` records the
429/// DPI ratio that produced the device-pixel buffer. The screenshot map absorbs
430/// the difference, because `image_to_screen` divides by `content_*`.
431#[derive(Debug, Clone, Copy, PartialEq)]
432#[cfg_attr(all(not(target_os = "windows"), not(test)), allow(dead_code))]
433pub struct WindowPlacement {
434    /// Left edge of the window in screen points.
435    pub origin_x: i32,
436    /// Top edge of the window in screen points.
437    pub origin_y: i32,
438    /// Window width in screen points.
439    pub logical_width: u32,
440    /// Window height in screen points.
441    pub logical_height: u32,
442    /// Capture backing scale (`dpi / 96`).
443    pub scale: f64,
444}
445
446/// Build a [`WindowPlacement`] from a window rect and its DPI.
447///
448/// `dpi` is whatever `GetDpiForWindow` returned. `0` — the documented "this
449/// window has no monitor" answer — is treated as [`DEFAULT_DPI`] so the scale
450/// stays 1.0. The rect is used verbatim for the logical size (never divided by
451/// the scale), which keeps the map agreeing with every other screen point the
452/// platform reports.
453#[cfg_attr(all(not(target_os = "windows"), not(test)), allow(dead_code))]
454pub fn window_placement(
455    origin_x: i32,
456    origin_y: i32,
457    rect_width: u32,
458    rect_height: u32,
459    dpi: u32,
460) -> WindowPlacement {
461    WindowPlacement {
462        origin_x,
463        origin_y,
464        logical_width: rect_width.max(1),
465        logical_height: rect_height.max(1),
466        scale: backing_scale(dpi),
467    }
468}
469
470/// `dpi / 96`, folding the invalid inputs (`0`, absurd ratios) to 1.0.
471#[cfg_attr(all(not(target_os = "windows"), not(test)), allow(dead_code))]
472pub fn backing_scale(dpi: u32) -> f64 {
473    let dpi = if dpi == 0 { DEFAULT_DPI } else { dpi };
474    normalize_scale(f64::from(dpi) / f64::from(DEFAULT_DPI))
475}
476
477/// A warning when a captured buffer does not match the window rect at the
478/// reported scale. Returns `None` when the two agree.
479///
480/// A mismatch is not an error — the screenshot map absorbs it. It is worth
481/// reporting because it usually means the window was resized between resolving
482/// it and the frame arriving.
483#[cfg_attr(all(not(target_os = "windows"), not(test)), allow(dead_code))]
484pub fn size_mismatch_warning(
485    pixel_width: u32,
486    pixel_height: u32,
487    placement: &WindowPlacement,
488) -> Option<String> {
489    let expected_w = (f64::from(placement.logical_width) * placement.scale).round() as u32;
490    let expected_h = (f64::from(placement.logical_height) * placement.scale).round() as u32;
491    if pixel_width == expected_w && pixel_height == expected_h {
492        return None;
493    }
494    Some(format!(
495        "captured frame is {pixel_width}x{pixel_height} px but the window rect implies \
496         {expected_w}x{expected_h} px at scale {}; the screenshot map uses the rect",
497        placement.scale,
498    ))
499}
500
501/// Draw `layers` into one image. Later layers paint over earlier ones.
502///
503/// The pixel grid is the union of the logical rectangles times `scale`.
504#[cfg(any(test, target_os = "macos"))]
505pub(crate) fn composite_layers(layers: &[Layer], scale: f64) -> Result<Composite, ComputerError> {
506    if layers.is_empty() {
507        return Err(ComputerError::ExecutionFailed(
508            "composite has no layers".into(),
509        ));
510    }
511    let scale = normalize_scale(scale);
512    let mut min_x = i64::MAX;
513    let mut min_y = i64::MAX;
514    let mut max_x = i64::MIN;
515    let mut max_y = i64::MIN;
516    for layer in layers {
517        let x = i64::from(layer.origin_x);
518        let y = i64::from(layer.origin_y);
519        min_x = min_x.min(x);
520        min_y = min_y.min(y);
521        max_x = max_x.max(x + i64::from(layer.logical_width.max(1)));
522        max_y = max_y.max(y + i64::from(layer.logical_height.max(1)));
523    }
524    let logical_width = u32::try_from(max_x - min_x).unwrap_or(u32::MAX).max(1);
525    let logical_height = u32::try_from(max_y - min_y).unwrap_or(u32::MAX).max(1);
526    let pixel_width = points_to_px(logical_width, scale);
527    let pixel_height = points_to_px(logical_height, scale);
528    checked_size(pixel_width, pixel_height)?;
529    let mut image = RgbaImage::from_pixel(pixel_width, pixel_height, Rgba([0, 0, 0, 255]));
530    for layer in layers {
531        let layer_w = points_to_px(layer.logical_width.max(1), scale);
532        let layer_h = points_to_px(layer.logical_height.max(1), scale);
533        let placed = resize_to(&layer.image, layer_w, layer_h);
534        let dx = ((layer.origin_x as f64 - min_x as f64) * scale).round() as i64;
535        let dy = ((layer.origin_y as f64 - min_y as f64) * scale).round() as i64;
536        image::imageops::overlay(&mut image, &placed, dx, dy);
537    }
538    Ok(Composite {
539        image,
540        origin_x: i32::try_from(min_x).unwrap_or(i32::MIN),
541        origin_y: i32::try_from(min_y).unwrap_or(i32::MIN),
542        logical_width,
543        logical_height,
544    })
545}
546
547fn parse_canvas_edge(text: &str, name: &str) -> Result<u32, ComputerError> {
548    let value = text.trim().parse::<u32>().map_err(|_| {
549        ComputerError::InvalidArguments(format!("canvas {name} is not an integer: {text:?}"))
550    })?;
551    if value == 0 {
552        return Err(ComputerError::InvalidArguments(
553            "canvas width and height must be greater than 0".into(),
554        ));
555    }
556    Ok(value)
557}
558
559fn limit_width(image: &RgbaImage, max_width: u32) -> Result<RgbaImage, ComputerError> {
560    if max_width == 0 || image.width() <= max_width {
561        return Ok(image.clone());
562    }
563    let height = u32::try_from(
564        (u64::from(image.height()) * u64::from(max_width)) / u64::from(image.width()),
565    )
566    .unwrap_or(1)
567    .max(1);
568    checked_size(max_width, height)?;
569    Ok(resize_to(image, max_width, height))
570}
571
572fn fit_inside(src_w: u32, src_h: u32, box_w: u32, box_h: u32) -> (u32, u32) {
573    if src_w <= box_w && src_h <= box_h {
574        return (src_w.max(1), src_h.max(1));
575    }
576    let scale = (f64::from(box_w) / f64::from(src_w.max(1)))
577        .min(f64::from(box_h) / f64::from(src_h.max(1)));
578    let width = (f64::from(src_w) * scale).floor().max(1.0) as u32;
579    let height = (f64::from(src_h) * scale).floor().max(1.0) as u32;
580    (width.min(box_w).max(1), height.min(box_h).max(1))
581}
582
583fn resize_to(image: &RgbaImage, width: u32, height: u32) -> RgbaImage {
584    if image.width() == width && image.height() == height {
585        image.clone()
586    } else {
587        image::imageops::resize(image, width, height, image::imageops::FilterType::Lanczos3)
588    }
589}
590
591#[cfg(any(test, target_os = "macos"))]
592fn points_to_px(points: u32, scale: f64) -> u32 {
593    let value = (f64::from(points) * scale).round();
594    if value < 1.0 {
595        1
596    } else if value > f64::from(u32::MAX) {
597        u32::MAX
598    } else {
599        value as u32
600    }
601}
602
603fn checked_size(width: u32, height: u32) -> Result<(), ComputerError> {
604    if width == 0 || height == 0 || width > MAX_EDGE || height > MAX_EDGE {
605        return Err(ComputerError::ExecutionFailed(format!(
606            "invalid capture size {width}x{height}"
607        )));
608    }
609    let bytes = u64::from(width)
610        .checked_mul(u64::from(height))
611        .and_then(|pixels| pixels.checked_mul(4))
612        .ok_or_else(|| ComputerError::ExecutionFailed("capture size overflow".into()))?;
613    if bytes > MAX_BYTES {
614        return Err(ComputerError::ExecutionFailed(
615            "capture is too large".into(),
616        ));
617    }
618    Ok(())
619}
620
621fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, ComputerError> {
622    let mut buffer = Cursor::new(Vec::new());
623    PngEncoder::new(&mut buffer)
624        .write_image(
625            image.as_raw(),
626            image.width(),
627            image.height(),
628            image::ExtendedColorType::Rgba8,
629        )
630        .map_err(|error| ComputerError::ExecutionFailed(format!("png encoding failed: {error}")))?;
631    Ok(buffer.into_inner())
632}
633
634fn encode_jpeg(image: &RgbaImage) -> Result<Vec<u8>, ComputerError> {
635    let rgb = rgba_to_rgb(image);
636    let mut buffer = Cursor::new(Vec::new());
637    JpegEncoder::new_with_quality(&mut buffer, JPEG_QUALITY)
638        .write_image(
639            &rgb,
640            image.width(),
641            image.height(),
642            image::ExtendedColorType::Rgb8,
643        )
644        .map_err(|error| {
645            ComputerError::ExecutionFailed(format!("jpeg encoding failed: {error}"))
646        })?;
647    Ok(buffer.into_inner())
648}
649
650fn rgba_to_rgb(image: &RgbaImage) -> Vec<u8> {
651    let mut rgb = Vec::with_capacity(image.as_raw().len() / 4 * 3);
652    for pixel in image.pixels() {
653        rgb.extend_from_slice(&pixel.0[..3]);
654    }
655    rgb
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    fn solid(width: u32, height: u32, rgba: [u8; 4]) -> RgbaImage {
663        RgbaImage::from_pixel(width, height, Rgba(rgba))
664    }
665
666    fn frame_solid(width: u32, height: u32) -> Frame {
667        Frame::from_image(solid(width, height, [255, 0, 0, 255]), 2.0)
668    }
669
670    #[test]
671    fn png_and_jpeg_magic_bytes() {
672        let frame = frame_solid(2, 2);
673        let png = encode_image(&frame, ImageFormat::Png, None).unwrap();
674        assert_eq!(&png.bytes[..4], &[0x89, b'P', b'N', b'G']);
675        assert_eq!((png.width, png.height), (2, 2));
676        let jpeg = encode_image(&frame, ImageFormat::Jpeg, None).unwrap();
677        assert_eq!(&jpeg.bytes[..2], &[0xFF, 0xD8]);
678    }
679
680    // ------------------------------------------------------------------
681    // P12b: Windows per-window capture geometry. Pure math, so it is
682    // tested on every runner rather than only in the Windows job.
683    // ------------------------------------------------------------------
684
685    #[test]
686    fn window_placement_keeps_the_rect_in_screen_points() {
687        let placement = window_placement(120, 40, 800, 500, 96);
688        assert_eq!(placement.origin_x, 120);
689        assert_eq!(placement.origin_y, 40);
690        assert_eq!(placement.logical_width, 800);
691        assert_eq!(placement.logical_height, 500);
692        assert_eq!(placement.scale, 1.0);
693    }
694
695    #[test]
696    fn backing_scale_is_dpi_over_96() {
697        assert_eq!(backing_scale(96), 1.0);
698        assert_eq!(backing_scale(120), 1.25);
699        assert_eq!(backing_scale(144), 1.5);
700        assert_eq!(backing_scale(192), 2.0);
701        // GetDpiForWindow reports 0 when the window has no monitor. That must
702        // never become a zero scale.
703        assert_eq!(backing_scale(0), 1.0);
704    }
705
706    #[test]
707    fn window_placement_never_reports_an_empty_logical_size() {
708        let placement = window_placement(0, 0, 0, 0, 96);
709        assert_eq!(placement.logical_width, 1);
710        assert_eq!(placement.logical_height, 1);
711    }
712
713    #[test]
714    fn a_150_percent_window_maps_its_device_pixels_into_the_rect() {
715        // A 1920x1200 device-pixel frame on a 1.5x display covers 1280x800
716        // screen points, which is exactly the rect the caller resolved.
717        let placement = window_placement(10, 20, 1280, 800, 144);
718        assert_eq!(placement.logical_width, 1280);
719        assert_eq!(placement.scale, 1.5);
720        assert_eq!(size_mismatch_warning(1920, 1200, &placement), None);
721
722        let prepared = prepare_shot(
723            &solid(1920, 1200, [0, 0, 0, 255]),
724            ShotFit {
725                scale: placement.scale,
726                origin_x: placement.origin_x,
727                origin_y: placement.origin_y,
728                logical_width: placement.logical_width,
729                logical_height: placement.logical_height,
730                max_width: 0,
731                canvas: None,
732            },
733        )
734        .unwrap();
735        // max_width 0 keeps the source size, so an image pixel is worth
736        // 1/1.5 screen points inside the rect.
737        let (x, y) = image_to_screen(&prepared.map, 150.0, 90.0);
738        assert!((x - 110.0).abs() < 0.001, "{x}");
739        assert!((y - 80.0).abs() < 0.001, "{y}");
740        // And the inverse round-trips.
741        let (ix, iy) = screen_to_image(&prepared.map, x, y);
742        assert!((ix - 150.0).abs() < 0.001, "{ix}");
743        assert!((iy - 90.0).abs() < 0.001, "{iy}");
744    }
745
746    #[test]
747    fn size_mismatch_warning_is_silent_when_the_buffer_matches() {
748        let placement = window_placement(0, 0, 640, 480, 96);
749        assert_eq!(size_mismatch_warning(640, 480, &placement), None);
750    }
751
752    #[test]
753    fn size_mismatch_warning_reports_a_window_resized_after_resolution() {
754        let placement = window_placement(0, 0, 640, 480, 96);
755        let warning = size_mismatch_warning(600, 480, &placement).expect("warning");
756        assert!(warning.contains("600x480"), "{warning}");
757        assert!(warning.contains("640x480"), "{warning}");
758    }
759
760    #[test]
761    fn size_mismatch_warning_names_the_scale_that_did_not_match() {
762        let placement = window_placement(0, 0, 1000, 1000, 192);
763        let warning = size_mismatch_warning(1000, 1000, &placement).expect("warning");
764        assert!(warning.contains("2000x2000"), "{warning}");
765        assert!(warning.contains("scale 2"), "{warning}");
766    }
767
768    #[test]
769    fn screen_rect_maps_a_platform_frame_into_image_pixels() {
770        // 1:1 map: a frame at the origin is the same rectangle in both spaces.
771        let map = map_for(800, 600, 0, 0, 800, 600);
772        assert_eq!(
773            screen_rect_to_image(&map, 100.0, 50.0, 200.0, 30.0),
774            Some((100, 50, 200, 30))
775        );
776    }
777
778    #[test]
779    fn screen_rect_honours_a_non_zero_origin() {
780        let map = map_for(800, 600, 120, 40, 800, 600);
781        assert_eq!(
782            screen_rect_to_image(&map, 120.0, 40.0, 10.0, 10.0),
783            Some((0, 0, 10, 10))
784        );
785        assert_eq!(
786            screen_rect_to_image(&map, 220.0, 140.0, 10.0, 10.0),
787            Some((100, 100, 10, 10))
788        );
789    }
790
791    #[test]
792    fn screen_rect_honours_a_downscaled_capture() {
793        // A 2x Retina frame halved into the image: half the screen size.
794        let map = map_for(1440, 900, 0, 0, 800, 500);
795        let (x, y, w, h) =
796            screen_rect_to_image(&map, 200.0, 100.0, 100.0, 50.0).expect("on canvas");
797        assert!((x - 360).abs() <= 1, "{x}");
798        assert!((y - 180).abs() <= 1, "{y}");
799        assert!((i64::from(w) - 180).abs() <= 1, "{w}");
800        assert!((i64::from(h) - 90).abs() <= 1, "{h}");
801    }
802
803    #[test]
804    fn screen_rect_offsets_by_the_canvas_letterbox_pad() {
805        // A 400x300 logical capture letterboxed into a 1000x900 canvas: the
806        // aspect is kept, so the drawn content is 1000x750 with pad (0, 75).
807        let mut map = map_for(1000, 900, 0, 0, 400, 300);
808        map.content_width = 1000;
809        map.content_height = 750;
810        map.pad = MapPoint { x: 0, y: 75 };
811        // x = 100 -> 0 + 100 * 1000/400 = 250
812        // y = 150 -> 75 + 150 * 750/300 = 450
813        // w = 20 -> 20 * 1000/400 = 50, h = 10 -> 10 * 750/300 = 25
814        assert_eq!(
815            screen_rect_to_image(&map, 100.0, 150.0, 20.0, 10.0),
816            Some((250, 450, 50, 25))
817        );
818        // The first drawn pixel column sits at the pad, not at zero.
819        assert_eq!(
820            screen_rect_to_image(&map, 0.0, 0.0, 400.0, 300.0),
821            Some((0, 75, 1000, 750))
822        );
823    }
824
825    #[test]
826    fn screen_rect_off_canvas_returns_none() {
827        let map = map_for(800, 600, 0, 0, 800, 600);
828        // Entirely left/above the capture.
829        assert_eq!(screen_rect_to_image(&map, -50.0, 10.0, 20.0, 10.0), None);
830        assert_eq!(screen_rect_to_image(&map, 10.0, -50.0, 20.0, 10.0), None);
831        // Past the far edge.
832        assert_eq!(screen_rect_to_image(&map, 900.0, 10.0, 20.0, 10.0), None);
833        // Overhanging the bottom-right corner past the one-pixel tolerance.
834        assert_eq!(screen_rect_to_image(&map, 795.0, 595.0, 100.0, 100.0), None);
835    }
836
837    #[test]
838    fn screen_rect_never_returns_a_zero_sized_box() {
839        let map = map_for(800, 600, 0, 0, 800, 600);
840        let (_, _, w, h) = screen_rect_to_image(&map, 100.0, 100.0, 0.0, 0.0).unwrap();
841        assert_eq!((w, h), (1, 1));
842    }
843
844    /// A 1:1 screenshot map for a capture whose logical size is
845    /// `logical_width` x `logical_height` at `origin`.
846    fn map_for(
847        width: u32,
848        height: u32,
849        origin_x: i32,
850        origin_y: i32,
851        logical_width: u32,
852        logical_height: u32,
853    ) -> ScreenshotMap {
854        ScreenshotMap {
855            width,
856            height,
857            scale: 1.0,
858            logical_width,
859            logical_height,
860            origin: MapPoint {
861                x: origin_x,
862                y: origin_y,
863            },
864            fit: "none".into(),
865            pad: MapPoint { x: 0, y: 0 },
866            content_width: width,
867            content_height: height,
868            map: MapFormulas {
869                image_to_screen: IMAGE_TO_SCREEN.into(),
870                screen_to_image: SCREEN_TO_IMAGE.into(),
871            },
872        }
873    }
874
875    #[test]
876    fn max_width_scales_height_and_zero_keeps_source_width() {
877        let frame = frame_solid(4, 2);
878        let encoded = encode_image(&frame, ImageFormat::Png, Some(2)).unwrap();
879        assert_eq!((encoded.width, encoded.height), (2, 1));
880        let full = encode_image(&frame, ImageFormat::Png, Some(0)).unwrap();
881        assert_eq!((full.width, full.height), (4, 2));
882        let same = encode_image(&frame, ImageFormat::Png, Some(8)).unwrap();
883        assert_eq!((same.width, same.height), (4, 2));
884    }
885
886    #[test]
887    fn base64_roundtrip() {
888        let text = encode_base64(b"hi");
889        assert_eq!(text, "aGk=");
890    }
891
892    #[test]
893    fn retina_max_width_1440_remaps_origin() {
894        let image = solid(1600, 1000, [10, 20, 30, 255]);
895        let prepared = prepare_shot(
896            &image,
897            ShotFit {
898                scale: 2.0,
899                origin_x: 120,
900                origin_y: 40,
901                logical_width: 800,
902                logical_height: 500,
903                max_width: 1440,
904                canvas: None,
905            },
906        )
907        .unwrap();
908        assert_eq!((prepared.map.width, prepared.map.height), (1440, 900));
909        assert_eq!(prepared.map.scale, 2.0);
910        assert_eq!(prepared.map.logical_width, 800);
911        assert_eq!(prepared.map.logical_height, 500);
912        assert_eq!(prepared.map.fit, "width");
913        assert_eq!((prepared.map.pad.x, prepared.map.pad.y), (0, 0));
914        assert_eq!(
915            (prepared.map.content_width, prepared.map.content_height),
916            (1440, 900)
917        );
918        assert_eq!(prepared.map.map.image_to_screen, IMAGE_TO_SCREEN);
919        let (sx, sy) = image_to_screen(&prepared.map, 0.0, 0.0);
920        assert_eq!((sx, sy), (120.0, 40.0));
921        let (sx, sy) = image_to_screen(&prepared.map, 1440.0, 900.0);
922        assert_eq!((sx, sy), (920.0, 540.0));
923        let (ix, iy) = screen_to_image(&prepared.map, 520.0, 290.0);
924        assert!((ix - 720.0).abs() < 0.001, "{ix}");
925        assert!((iy - 450.0).abs() < 0.001, "{iy}");
926        let (back_x, back_y) = image_to_screen(&prepared.map, ix, iy);
927        assert!((back_x - 520.0).abs() < 1.0);
928        assert!((back_y - 290.0).abs() < 1.0);
929    }
930
931    #[test]
932    fn canvas_1440x900_records_padding() {
933        let image = solid(800, 200, [1, 2, 3, 255]);
934        let prepared = prepare_shot(
935            &image,
936            ShotFit {
937                scale: 1.0,
938                origin_x: 120,
939                origin_y: 40,
940                logical_width: 800,
941                logical_height: 200,
942                max_width: 1440,
943                canvas: Some((1440, 900)),
944            },
945        )
946        .unwrap();
947        assert_eq!((prepared.map.width, prepared.map.height), (1440, 900));
948        assert_eq!(prepared.map.fit, "canvas");
949        assert_eq!(
950            (prepared.map.content_width, prepared.map.content_height),
951            (800, 200)
952        );
953        assert!(prepared.map.pad.x > 0);
954        assert!(prepared.map.pad.y > 0);
955        assert_eq!((prepared.map.pad.x, prepared.map.pad.y), (320, 350));
956        let (sx, sy) = image_to_screen(
957            &prepared.map,
958            prepared.map.pad.x as f64,
959            prepared.map.pad.y as f64,
960        );
961        assert_eq!((sx, sy), (120.0, 40.0));
962        let (left, _) = image_to_screen(&prepared.map, 0.0, prepared.map.pad.y as f64);
963        assert!(left < prepared.map.origin.x as f64);
964        assert_eq!(prepared.image.get_pixel(0, 0).0, [0, 0, 0, 255]);
965        assert_eq!(
966            prepared
967                .image
968                .get_pixel(prepared.map.pad.x as u32, prepared.map.pad.y as u32)
969                .0,
970            [1, 2, 3, 255]
971        );
972    }
973
974    #[test]
975    fn known_pixel_round_trips_through_encode() {
976        let mut image = solid(100, 50, [255, 0, 0, 255]);
977        image.put_pixel(40, 20, Rgba([0, 255, 0, 255]));
978        let prepared = prepare_shot(
979            &image,
980            ShotFit {
981                scale: 2.0,
982                origin_x: 120,
983                origin_y: 40,
984                logical_width: 50,
985                logical_height: 25,
986                max_width: 1440,
987                canvas: None,
988            },
989        )
990        .unwrap();
991        assert_eq!((prepared.map.width, prepared.map.height), (100, 50));
992        let encoded = encode_rgba(&prepared.image, ImageFormat::Png).unwrap();
993        let decoded = image::load_from_memory(&encoded.bytes).unwrap().to_rgba8();
994        assert_eq!(decoded.get_pixel(40, 20).0, [0, 255, 0, 255]);
995        let (sx, sy) = image_to_screen(&prepared.map, 40.0, 20.0);
996        assert!((sx - 140.0).abs() <= 1.0, "{sx}");
997        assert!((sy - 50.0).abs() <= 1.0, "{sy}");
998        let (ix, iy) = screen_to_image(&prepared.map, sx, sy);
999        assert!((ix - 40.0).abs() <= 1.0, "{ix}");
1000        assert!((iy - 20.0).abs() <= 1.0, "{iy}");
1001    }
1002
1003    #[test]
1004    fn composite_places_sheet_above_parent() {
1005        let parent = Layer {
1006            image: solid(4, 4, [255, 0, 0, 255]),
1007            origin_x: 10,
1008            origin_y: 20,
1009            logical_width: 4,
1010            logical_height: 4,
1011        };
1012        let sheet = Layer {
1013            image: solid(2, 2, [0, 255, 0, 255]),
1014            origin_x: 11,
1015            origin_y: 21,
1016            logical_width: 2,
1017            logical_height: 2,
1018        };
1019        let union = composite_layers(&[parent, sheet], 1.0).unwrap();
1020        assert_eq!((union.origin_x, union.origin_y), (10, 20));
1021        assert_eq!((union.logical_width, union.logical_height), (4, 4));
1022        assert_eq!(union.image.get_pixel(0, 0).0, [255, 0, 0, 255]);
1023        assert_eq!(union.image.get_pixel(1, 1).0, [0, 255, 0, 255]);
1024    }
1025
1026    #[test]
1027    fn fake_sck_errors_map_to_faults() {
1028        assert_eq!(
1029            classify_sck_error("SCStreamErrorDomain", -3801, "declined"),
1030            CaptureFault::Permission
1031        );
1032        assert_eq!(
1033            classify_sck_error("com.apple.TCC", 0, "Screen Recording denied"),
1034            CaptureFault::Permission
1035        );
1036        assert_eq!(
1037            classify_sck_error("SCStreamErrorDomain", -3812, "window not found"),
1038            CaptureFault::MissingWindow
1039        );
1040        assert_eq!(
1041            classify_sck_error("SCStreamErrorDomain", -3802, "failed to start"),
1042            CaptureFault::Other
1043        );
1044        assert!(window_absent(false, false));
1045        assert!(!window_absent(true, false));
1046        assert!(!window_absent(false, true));
1047    }
1048
1049    #[test]
1050    fn display_facts_map_before_window_not_found() {
1051        let clear = DisplayFacts {
1052            screen_locked: false,
1053            display_asleep: false,
1054            display_count: Some(1),
1055            shareable_window_count: Some(2),
1056        };
1057        assert!(display_block_reason(clear).is_none());
1058        let missing = error_for_missing_window(clear, 42);
1059        assert_eq!(missing.code(), "window_not_found");
1060        assert!(missing.to_string().contains("42"));
1061
1062        let locked = DisplayFacts {
1063            screen_locked: true,
1064            ..clear
1065        };
1066        let error = error_for_missing_window(locked, 42);
1067        assert_eq!(error.code(), "display_unavailable");
1068        assert!(error.to_string().contains("locked"));
1069
1070        let asleep = DisplayFacts {
1071            display_asleep: true,
1072            display_count: Some(0),
1073            shareable_window_count: Some(0),
1074            ..clear
1075        };
1076        assert_eq!(
1077            error_for_missing_window(asleep, 7).code(),
1078            "display_unavailable"
1079        );
1080        assert!(error_for_missing_window(asleep, 7)
1081            .to_string()
1082            .contains("asleep"));
1083
1084        let no_display = DisplayFacts {
1085            display_count: Some(0),
1086            shareable_window_count: None,
1087            ..clear
1088        };
1089        assert_eq!(
1090            display_block_reason(no_display),
1091            Some("no display available")
1092        );
1093
1094        let no_windows = DisplayFacts {
1095            shareable_window_count: Some(0),
1096            ..clear
1097        };
1098        let error = error_for_missing_window(no_windows, 9);
1099        assert_eq!(error.code(), "display_unavailable");
1100        assert!(error.to_string().contains("no windows"));
1101    }
1102
1103    #[test]
1104    fn parse_canvas_accepts_separators() {
1105        assert_eq!(parse_canvas("1440x900").unwrap(), (1440, 900));
1106        assert_eq!(parse_canvas("1440X900").unwrap(), (1440, 900));
1107        assert_eq!(parse_canvas("1440×900").unwrap(), (1440, 900));
1108        assert!(parse_canvas("1440").is_err());
1109        assert!(parse_canvas("0x10").is_err());
1110    }
1111}