1use 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
18pub 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#[cfg(any(test, target_os = "macos"))]
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub(crate) enum CaptureFault {
30 Permission,
32 MissingWindow,
34 Other,
36}
37
38#[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#[cfg(test)]
61fn window_absent(on_screen_found: bool, off_screen_found: bool) -> bool {
62 !on_screen_found && !off_screen_found
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub(crate) struct DisplayFacts {
68 pub screen_locked: bool,
70 pub display_asleep: bool,
72 pub display_count: Option<usize>,
74 pub shareable_window_count: Option<usize>,
76}
77
78pub(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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110pub struct MapPoint {
111 pub x: i32,
113 pub y: i32,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct MapFormulas {
120 pub image_to_screen: String,
122 pub screen_to_image: String,
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct ScreenshotMap {
133 pub width: u32,
135 pub height: u32,
137 pub scale: f64,
139 pub logical_width: u32,
141 pub logical_height: u32,
143 pub origin: MapPoint,
145 pub fit: String,
147 pub pad: MapPoint,
149 pub content_width: u32,
151 pub content_height: u32,
153 pub map: MapFormulas,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct EncodedImage {
160 pub bytes: Vec<u8>,
162 pub width: u32,
164 pub height: u32,
166}
167
168#[derive(Debug, Clone, PartialEq)]
170pub struct PreparedShot {
171 pub image: RgbaImage,
173 pub map: ScreenshotMap,
175}
176
177#[cfg(any(test, target_os = "macos"))]
179#[derive(Debug, Clone)]
180pub(crate) struct Layer {
181 pub image: RgbaImage,
183 pub origin_x: i32,
185 pub origin_y: i32,
187 pub logical_width: u32,
189 pub logical_height: u32,
191}
192
193#[cfg(any(test, target_os = "macos"))]
195#[derive(Debug, Clone)]
196pub(crate) struct Composite {
197 pub image: RgbaImage,
199 pub origin_x: i32,
201 pub origin_y: i32,
203 pub logical_width: u32,
205 pub logical_height: u32,
207}
208
209pub 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
221pub 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
234pub fn encode_base64(bytes: &[u8]) -> String {
236 base64::engine::general_purpose::STANDARD.encode(bytes)
237}
238
239pub 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
252pub struct ShotFit {
254 pub scale: f64,
256 pub origin_x: i32,
258 pub origin_y: i32,
260 pub logical_width: u32,
262 pub logical_height: u32,
264 pub max_width: u32,
266 pub canvas: Option<(u32, u32)>,
268}
269
270pub 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
355pub 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
366pub 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#[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))]
420pub const DEFAULT_DPI: u32 = 96;
422
423#[derive(Debug, Clone, Copy, PartialEq)]
432#[cfg_attr(all(not(target_os = "windows"), not(test)), allow(dead_code))]
433pub struct WindowPlacement {
434 pub origin_x: i32,
436 pub origin_y: i32,
438 pub logical_width: u32,
440 pub logical_height: u32,
442 pub scale: f64,
444}
445
446#[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#[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#[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#[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 #[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 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 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 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 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 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 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 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 assert_eq!(
815 screen_rect_to_image(&map, 100.0, 150.0, 20.0, 10.0),
816 Some((250, 450, 50, 25))
817 );
818 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 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 assert_eq!(screen_rect_to_image(&map, 900.0, 10.0, 20.0, 10.0), None);
833 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 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}