diff --git a/apps/desktop-gpui/src/editor_sidebar.rs b/apps/desktop-gpui/src/editor_sidebar.rs index d11bb7172e..dcc1c0f2c4 100644 --- a/apps/desktop-gpui/src/editor_sidebar.rs +++ b/apps/desktop-gpui/src/editor_sidebar.rs @@ -1923,22 +1923,7 @@ fn decode_scaled_rgba(path: &Path, max: u32) -> Option { } fn decode_jpeg_thumbnail(bytes: &[u8], max: u32) -> Option<(image::DynamicImage, u32, u32)> { - let mut decoder = jpeg_decoder::Decoder::new(bytes); - decoder.read_info().ok()?; - let info = decoder.info()?; - let requested = max.clamp(1, u16::MAX as u32) as u16; - let (width, height) = decoder.scale(requested, requested).ok()?; - let pixels = decoder.decode().ok()?; - let decoded = match info.pixel_format { - jpeg_decoder::PixelFormat::RGB24 => image::DynamicImage::ImageRgb8( - image::RgbImage::from_raw(width as u32, height as u32, pixels)?, - ), - jpeg_decoder::PixelFormat::L8 => image::DynamicImage::ImageLuma8( - image::GrayImage::from_raw(width as u32, height as u32, pixels)?, - ), - _ => return None, - }; - Some((decoded, info.width as u32, info.height as u32)) + crate::jpeg_thumbnail::decode_jpeg_thumbnail(bytes, |_, _| (max, max)) } /// [`decode_scaled_rgba`] in gpui's BGRA order. diff --git a/apps/desktop-gpui/src/jpeg_thumbnail.rs b/apps/desktop-gpui/src/jpeg_thumbnail.rs new file mode 100644 index 0000000000..85549b99a6 --- /dev/null +++ b/apps/desktop-gpui/src/jpeg_thumbnail.rs @@ -0,0 +1,210 @@ +use image::DynamicImage; + +pub fn decode_jpeg_thumbnail( + bytes: &[u8], + request: impl FnOnce(u32, u32) -> (u32, u32), +) -> Option<(DynamicImage, u32, u32)> { + let mut decoder = jpeg_decoder::Decoder::new(bytes); + decoder.read_info().ok()?; + let info = decoder.info()?; + match info.pixel_format { + jpeg_decoder::PixelFormat::RGB24 | jpeg_decoder::PixelFormat::L8 => {} + _ => return None, + } + let (requested_width, requested_height) = + request(u32::from(info.width), u32::from(info.height)); + let requested_width = requested_width.clamp(1, u32::from(u16::MAX)) as u16; + let requested_height = requested_height.clamp(1, u32::from(u16::MAX)) as u16; + let (width, height) = decoder.scale(requested_width, requested_height).ok()?; + let pixels = decoder.decode().ok()?; + let decoded = match info.pixel_format { + jpeg_decoder::PixelFormat::RGB24 => DynamicImage::ImageRgb8(image::RgbImage::from_raw( + u32::from(width), + u32::from(height), + pixels, + )?), + jpeg_decoder::PixelFormat::L8 => DynamicImage::ImageLuma8(image::GrayImage::from_raw( + u32::from(width), + u32::from(height), + pixels, + )?), + _ => return None, + }; + Some((decoded, u32::from(info.width), u32::from(info.height))) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ThumbnailResize { + Box, + Triangle, + Identity, +} + +pub fn thumbnail_resize( + decoded_width: u32, + decoded_height: u32, + target_width: u32, + target_height: u32, +) -> ThumbnailResize { + let decoded_width = decoded_width.max(1); + let decoded_height = decoded_height.max(1); + let target_width = target_width.max(1); + let target_height = target_height.max(1); + if decoded_width <= target_width && decoded_height <= target_height { + return ThumbnailResize::Identity; + } + let scale = (target_width as f32 / decoded_width as f32) + .min(target_height as f32 / decoded_height as f32); + if scale <= 0.5 { + ThumbnailResize::Box + } else { + ThumbnailResize::Triangle + } +} + +pub fn cover_target(width: u32, height: u32, card_width: u32, card_height: u32) -> (f32, u32, u32) { + let width = width.max(1); + let height = height.max(1); + let scale = (card_width as f32 / width as f32) + .max(card_height as f32 / height as f32) + .min(1.0); + let target_width = ((width as f32 * scale).round() as u32).max(1); + let target_height = ((height as f32 * scale).round() as u32).max(1); + (scale, target_width, target_height) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CARD_WIDTH: u32 = 392; + const CARD_HEIGHT: u32 = 224; + + fn encode_rgb(width: u32, height: u32, pixel: [u8; 3]) -> Vec { + let image = image::RgbImage::from_pixel(width, height, image::Rgb(pixel)); + let mut encoded = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 75) + .encode_image(&image) + .unwrap(); + encoded + } + + #[test] + fn square_request_downsamples_before_the_full_frame() { + let encoded = encode_rgb(1024, 512, [40, 120, 200]); + let (decoded, width, height) = decode_jpeg_thumbnail(&encoded, |_, _| (128, 128)).unwrap(); + assert_eq!((width, height), (1024, 512)); + assert!(decoded.width() <= 256); + assert!(decoded.height() <= 128); + let pixel = decoded.to_rgb8().get_pixel(0, 0).0; + assert!(pixel[0].abs_diff(40) <= 3); + assert!(pixel[1].abs_diff(120) <= 3); + assert!(pixel[2].abs_diff(200) <= 3); + } + + #[test] + fn library_cover_targets_match_the_card() { + assert_eq!( + cover_dims(800, 600), + (392, 294), + "800x600 covering the card" + ); + assert_eq!(cover_dims(600, 600), (392, 392)); + assert_eq!(cover_dims(1920, 1080), (398, 224)); + assert_eq!(cover_dims(1512, 982), (392, 255)); + } + + #[test] + fn resize_uses_the_decoded_buffer_not_the_original_scale() { + let (_, target_width, target_height) = cover_target(1920, 1080, CARD_WIDTH, CARD_HEIGHT); + assert_eq!((target_width, target_height), (398, 224)); + assert_eq!( + thumbnail_resize(1920, 1080, target_width, target_height), + ThumbnailResize::Box + ); + assert_eq!( + thumbnail_resize(480, 270, target_width, target_height), + ThumbnailResize::Triangle + ); + assert_eq!( + thumbnail_resize(target_width, target_height, target_width, target_height), + ThumbnailResize::Identity + ); + } + + #[test] + fn library_cover_idct_scales_1080p_to_a_quarter() { + let encoded = encode_rgb(1920, 1080, [40, 120, 200]); + let (decoded, width, height) = decode_jpeg_thumbnail(&encoded, |w, h| { + let (_, target_width, target_height) = cover_target(w, h, CARD_WIDTH, CARD_HEIGHT); + (target_width, target_height) + }) + .unwrap(); + assert_eq!((width, height), (1920, 1080)); + assert_eq!((decoded.width(), decoded.height()), (480, 270)); + let (_, target_width, target_height) = cover_target(width, height, CARD_WIDTH, CARD_HEIGHT); + assert_eq!( + thumbnail_resize( + decoded.width(), + decoded.height(), + target_width, + target_height + ), + ThumbnailResize::Triangle + ); + } + + #[test] + fn cmyk_frame_returns_none() { + let bytes = cmyk_jpeg(); + let mut decoder = jpeg_decoder::Decoder::new(bytes.as_slice()); + let pixels = decoder.decode().expect("cmyk fixture decodes"); + assert_eq!( + decoder.info().map(|info| info.pixel_format), + Some(jpeg_decoder::PixelFormat::CMYK32) + ); + assert_eq!(pixels.len(), 8 * 8 * 4); + assert!(decode_jpeg_thumbnail(&bytes, |w, h| (w, h)).is_none()); + } + + fn cmyk_jpeg() -> Vec { + fn marker(out: &mut Vec, tag: u8, payload: &[u8]) { + out.extend_from_slice(&[0xFF, tag]); + let len = u16::try_from(payload.len() + 2).unwrap(); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(payload); + } + fn huffman(class: u8) -> Vec { + let mut table = vec![class, 0x01]; + table.extend(std::iter::repeat_n(0x00, 15)); + table.push(0x00); + table + } + let mut quant = vec![0x00]; + quant.extend(std::iter::repeat_n(0x01, 64)); + let mut frame = vec![0x08, 0x00, 0x08, 0x00, 0x08, 0x04]; + for id in 1..=4u8 { + frame.extend_from_slice(&[id, 0x11, 0x00]); + } + let mut scan = vec![0x04]; + for id in 1..=4u8 { + scan.extend_from_slice(&[id, 0x00]); + } + scan.extend_from_slice(&[0x00, 0x3F, 0x00]); + + let mut bytes = vec![0xFF, 0xD8]; + marker(&mut bytes, 0xDB, &quant); + marker(&mut bytes, 0xC0, &frame); + marker(&mut bytes, 0xC4, &huffman(0x00)); + marker(&mut bytes, 0xC4, &huffman(0x10)); + marker(&mut bytes, 0xDA, &scan); + bytes.push(0x00); + bytes.extend_from_slice(&[0xFF, 0xD9]); + bytes + } + + fn cover_dims(width: u32, height: u32) -> (u32, u32) { + let (_, target_width, target_height) = cover_target(width, height, CARD_WIDTH, CARD_HEIGHT); + (target_width, target_height) + } +} diff --git a/apps/desktop-gpui/src/library.rs b/apps/desktop-gpui/src/library.rs index a40e6ae61f..3bff923b14 100644 --- a/apps/desktop-gpui/src/library.rs +++ b/apps/desktop-gpui/src/library.rs @@ -1097,32 +1097,50 @@ pub fn thumbnail_rgba(path: &Path) -> Option { // Sniff rather than trust the extension: `list_screenshots` finds the // preview by extension scan, and a bundle could hold a mislabelled file. let format = image::guess_format(&bytes).ok()?; - let decoded = image::load_from_memory_with_format(&bytes, format).ok()?; - - let (width, height) = (decoded.width().max(1), decoded.height().max(1)); - let scale = (THUMBNAIL_WIDTH as f32 / width as f32) - .max(THUMBNAIL_HEIGHT as f32 / height as f32) - .min(1.0); - let target_width = ((width as f32 * scale).round() as u32).max(1); - let target_height = ((height as f32 * scale).round() as u32).max(1); + let (decoded, width, height) = if format == image::ImageFormat::Jpeg { + crate::jpeg_thumbnail::decode_jpeg_thumbnail(&bytes, |width, height| { + let (_, target_width, target_height) = crate::jpeg_thumbnail::cover_target( + width, + height, + THUMBNAIL_WIDTH, + THUMBNAIL_HEIGHT, + ); + (target_width, target_height) + }) + .or_else(|| { + let decoded = image::load_from_memory_with_format(&bytes, format).ok()?; + let dimensions = (decoded.width(), decoded.height()); + Some((decoded, dimensions.0, dimensions.1)) + })? + } else { + let decoded = image::load_from_memory_with_format(&bytes, format).ok()?; + let dimensions = (decoded.width(), decoded.height()); + (decoded, dimensions.0, dimensions.1) + }; + let (width, height) = (width.max(1), height.max(1)); + let (_, target_width, target_height) = + crate::jpeg_thumbnail::cover_target(width, height, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); // Box sampling for the big ratios (one pass over a multi-megapixel // source, and at 8x down every output pixel averages a whole block); // Triangle when the sizes are close, where box would alias and costs // nothing to avoid. - let rgba = if scale <= 0.5 { - decoded + let rgba = match crate::jpeg_thumbnail::thumbnail_resize( + decoded.width(), + decoded.height(), + target_width, + target_height, + ) { + crate::jpeg_thumbnail::ThumbnailResize::Box => decoded .thumbnail_exact(target_width, target_height) - .into_rgba8() - } else if scale < 1.0 { - decoded + .into_rgba8(), + crate::jpeg_thumbnail::ThumbnailResize::Triangle => decoded .resize_exact( target_width, target_height, image::imageops::FilterType::Triangle, ) - .into_rgba8() - } else { - decoded.into_rgba8() + .into_rgba8(), + crate::jpeg_thumbnail::ThumbnailResize::Identity => decoded.into_rgba8(), }; if let Some(cache) = &cache { diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index b47afeb80f..ecdc470116 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -43,6 +43,7 @@ mod feeds; mod hotkeys; mod import; mod installer; +mod jpeg_thumbnail; mod library; mod main_window; mod media_access;