notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 009b4cf6b04b872ff6ad8261bb746d617919d17e
parent c891f8585d711d5e8f76147cd340bfd20dd6d675
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 25 Jul 2025 10:52:27 -0700

images: always resize large images

Fixes: https://github.com/damus-io/notedeck/issues/451
Fixes: https://linear.app/damus/issue/DECK-556/resize-images-to-device-screen-size
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mcrates/notedeck_columns/src/ui/note/post.rs | 2+-
Mcrates/notedeck_ui/src/images.rs | 62+++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/notedeck_ui/src/note/media.rs | 12+++++++++---
3 files changed, 61 insertions(+), 15 deletions(-)

diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -471,7 +471,7 @@ impl<'a, 'd> PostView<'a, 'd> { self.note_context.img_cache, cache_type, url, - notedeck_ui::images::ImageType::Content, + notedeck_ui::images::ImageType::Content(Some((width, height))), ); render_post_view_media( diff --git a/crates/notedeck_ui/src/images.rs b/crates/notedeck_ui/src/images.rs @@ -106,19 +106,58 @@ pub fn round_image(image: &mut ColorImage) { } } +/// If the image's longest dimension is greater than max_edge, downscale +fn resize_image_if_too_big( + image: image::DynamicImage, + max_edge: u32, + filter: FilterType, +) -> image::DynamicImage { + // if we have no size hint, resize to something reasonable + let w = image.width(); + let h = image.height(); + let long = w.max(h); + + if long > max_edge { + let scale = max_edge as f32 / long as f32; + let new_w = (w as f32 * scale).round() as u32; + let new_h = (h as f32 * scale).round() as u32; + + image.resize(new_w, new_h, filter) + } else { + image + } +} + +/// +/// Process an image, resizing so we don't blow up video memory or even crash +/// +/// For profile pictures, make them round and small to fit the size hint +/// For everything else, either: +/// +/// - resize to the size hint +/// - keep the size if the longest dimension is less than MAX_IMG_LENGTH +/// - resize if any larger, using [`resize_image_if_too_big`] +/// #[profiling::function] -fn process_pfp_bitmap(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { +fn process_image(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { + const MAX_IMG_LENGTH: u32 = 512; + const FILTER_TYPE: FilterType = FilterType::CatmullRom; + match imgtyp { - ImageType::Content => { - let image_buffer = image.clone().into_rgba8(); - let color_image = ColorImage::from_rgba_unmultiplied( + ImageType::Content(size_hint) => { + let image = match size_hint { + None => resize_image_if_too_big(image, MAX_IMG_LENGTH, FILTER_TYPE), + Some((w, h)) => image.resize(w, h, FILTER_TYPE), + }; + + let image_buffer = image.into_rgba8(); + ColorImage::from_rgba_unmultiplied( [ image_buffer.width() as usize, image_buffer.height() as usize, ], image_buffer.as_flat_samples().as_slice(), - ); - color_image + ) } ImageType::Profile(size) => { // Crop square @@ -154,7 +193,8 @@ fn parse_img_response( let content_type = response.content_type().unwrap_or_default(); let size_hint = match imgtyp { ImageType::Profile(size) => SizeHint::Size(size, size), - ImageType::Content => SizeHint::default(), + ImageType::Content(Some((w, h))) => SizeHint::Size(w, h), + ImageType::Content(None) => SizeHint::default(), }; if content_type.starts_with("image/svg") { @@ -167,7 +207,7 @@ fn parse_img_response( } else if content_type.starts_with("image/") { profiling::scope!("load_from_memory"); let dyn_image = image::load_from_memory(&response.bytes)?; - Ok(process_pfp_bitmap(imgtyp, dyn_image)) + Ok(process_image(imgtyp, dyn_image)) } else { Err(format!("Expected image, found content-type {content_type:?}").into()) } @@ -351,8 +391,8 @@ pub fn fetch_binary_from_disk(path: PathBuf) -> Result<Vec<u8>, notedeck::Error> pub enum ImageType { /// Profile Image (size) Profile(u32), - /// Content Image - Content, + /// Content Image with optional size hint + Content(Option<(u32, u32)>), } pub fn fetch_img( @@ -411,7 +451,7 @@ fn fetch_img_from_net( &cache_path, gif_bytes, true, - move |img| process_pfp_bitmap(imgtyp, img), + move |img| process_image(imgtyp, img), ) } } diff --git a/crates/notedeck_ui/src/note/media.rs b/crates/notedeck_ui/src/note/media.rs @@ -90,7 +90,7 @@ pub(crate) fn image_carousel( url, *media_type, cache, - ImageType::Content, + ImageType::Content(Some((width as u32, height as u32))), ); } } @@ -201,7 +201,7 @@ fn show_full_screen_media( img_cache, media_type, image_url, - ImageType::Content, + ImageType::Content(None), ); let notedeck::TextureState::Loaded(textured_image) = cur_state.texture_state else { @@ -285,7 +285,13 @@ pub fn get_content_media_render_state<'a>( ) -> MediaRenderState<'a> { let render_type = if media_trusted { cache.handle_and_get_or_insert_loadable(url, || { - crate::images::fetch_img(cache_dir, ui.ctx(), url, ImageType::Content, cache_type) + crate::images::fetch_img( + cache_dir, + ui.ctx(), + url, + ImageType::Content(None), + cache_type, + ) }) } else if let Some(render_type) = cache.get_and_handle(url) { render_type