notedeck

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

commit 9a5d7a1d34922b0510255723e819479029201879
parent d3659c20cd6f5ca4c01300db9b5a727d5d9add34
Author: William Casarin <jb55@jb55.com>
Date:   Wed,  4 Feb 2026 11:46:18 -0800

Merge remote-tracking branch 'kernel/fix-banner-perf'

This fixes a performance issue with profile banners (#918) by switching
from egui_extras image loading to our custom image loader with proper
size hints, preventing massive banners from being loaded into memory.

Changes:

- refactor(media): Change `ImageType::Content(Option<(u32, u32)>)` to
  use `PixelDimensions` for better type safety. Make `PixelDimensions`
  Copy since it's just two u32 values.

- perf-fix(banner): Replace `egui::Image::new().load_for_size()` with
  `Images::latest_texture()` which uses our job system. Pass size hints
  to avoid loading full-resolution banners. Add empty_banner()
  placeholder while loading.

- perf(carousel): Downscale images from disk when the cached image
  exceeds the requested size, avoiding unnecessary memory usage.

kernelkind (3):
      refactor(media): make `ImageType` more obvious it uses pixels
      perf-fix(banner): use our custom img loader via jobs for banner
      perf(carousel): downscale from disk if possible

Closes: https://github.com/damus-io/notedeck/issues/918
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mcrates/notedeck/src/media/blur.rs | 10+++++++---
Mcrates/notedeck/src/media/images.rs | 7++++---
Mcrates/notedeck/src/media/static_imgs.rs | 27++++++++++++++++++++-------
Mcrates/notedeck_columns/src/ui/note/post.rs | 24+++++++++++-------------
Mcrates/notedeck_columns/src/ui/profile/edit.rs | 2+-
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 2++
Mcrates/notedeck_ui/src/note/media.rs | 8+++++---
Mcrates/notedeck_ui/src/profile/mod.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/notedeck_ui/src/profile/preview.rs | 2++
9 files changed, 102 insertions(+), 50 deletions(-)

diff --git a/crates/notedeck/src/media/blur.rs b/crates/notedeck/src/media/blur.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use egui::TextureHandle; +use egui::{TextureHandle, Vec2}; use nostrdb::Note; use crate::{ @@ -18,7 +18,7 @@ pub struct ImageMetadata { pub dimensions: Option<PixelDimensions>, // width and height in pixels } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Copy)] pub struct PixelDimensions { pub x: u32, pub y: u32, @@ -53,6 +53,10 @@ pub struct PointDimensions { } impl PointDimensions { + pub fn from_vec(vec: Vec2) -> Self { + Self { x: vec.x, y: vec.y } + } + pub fn to_pixels(self, ui: &egui::Ui) -> PixelDimensions { PixelDimensions { x: (self.x * ui.pixels_per_point()).round() as u32, @@ -83,7 +87,7 @@ impl ImageMetadata { } if defined_dimensions.y <= max_pixels.y { - return defined_dimensions.clone(); + return *defined_dimensions; } let scale_factor = (max_pixels.y as f32) / (defined_dimensions.y as f32); diff --git a/crates/notedeck/src/media/images.rs b/crates/notedeck/src/media/images.rs @@ -1,4 +1,5 @@ use crate::media::network::HyperHttpResponse; +use crate::PixelDimensions; use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint}; use image::imageops::FilterType; use image::FlatSamples; @@ -134,7 +135,7 @@ pub fn process_image(imgtyp: ImageType, mut image: image::DynamicImage) -> Color 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), + Some(pixels) => image.resize(pixels.x, pixels.y, FILTER_TYPE), }; let image_buffer = image.into_rgba8(); @@ -180,7 +181,7 @@ pub 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(Some((w, h))) => SizeHint::Size(w, h), + ImageType::Content(Some(pixels)) => SizeHint::Size(pixels.x, pixels.y), ImageType::Content(None) => SizeHint::default(), }; @@ -220,5 +221,5 @@ pub enum ImageType { /// Profile Image (size) Profile(u32), /// Content Image with optional size hint - Content(Option<(u32, u32)>), + Content(Option<PixelDimensions>), } diff --git a/crates/notedeck/src/media/static_imgs.rs b/crates/notedeck/src/media/static_imgs.rs @@ -15,7 +15,7 @@ use crate::{ }; use crate::{ media::{ - images::{buffer_to_color_image, parse_img_response}, + images::{buffer_to_color_image, parse_img_response, process_image}, load_texture_checked, network::http_req, }, @@ -75,7 +75,7 @@ impl StaticImgTexCache { MediaJobKind::StaticImg, RunType::Output(JobRun::Sync(Box::new(move || { JobOutput::Complete(CompleteResponse::new(MediaJobResult::StaticImg( - fetch_static_img_from_disk(ctx.clone(), &url, &path), + fetch_static_img_from_disk(ctx.clone(), &url, imgtype, &path), ))) }))), )) { @@ -102,9 +102,11 @@ impl StaticImgTexCache { } } +/// Loads a cached static image, resizing only when the stored image exceeds the requested [`ImageType`]. pub fn fetch_static_img_from_disk( ctx: egui::Context, url: &str, + img_type: ImageType, path: &Path, ) -> Result<egui::TextureHandle, crate::Error> { tracing::trace!("Starting job static img from disk for {url}"); @@ -119,11 +121,14 @@ pub fn fetch_static_img_from_disk( } }; - let img = buffer_to_color_image( - image_buffer.as_flat_samples_u8(), - image_buffer.width(), - image_buffer.height(), - ); + let width = image_buffer.width(); + let height = image_buffer.height(); + + let img = if needs_resize(img_type, width, height) { + process_image(img_type, image_buffer) + } else { + buffer_to_color_image(image_buffer.as_flat_samples_u8(), width, height) + }; Ok(load_texture_checked(&ctx, url, img, Default::default())) } @@ -170,3 +175,11 @@ async fn fetch_static_img_from_net( ) }))) } + +fn needs_resize(img_type: ImageType, width: u32, height: u32) -> bool { + match img_type { + ImageType::Profile(size) => width > size || height > size, + ImageType::Content(Some(dimensions)) => width > dimensions.x || height > dimensions.y, + ImageType::Content(None) => false, + } +} diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -525,10 +525,13 @@ impl<'a, 'd> PostView<'a, 'd> { fn show_media(&mut self, ui: &mut egui::Ui) { let mut to_remove = Vec::new(); for (i, media) in self.draft.uploaded_media.iter().enumerate() { - let (width, height) = if let Some(dims) = media.dimensions { - (dims.0, dims.1) + let pixel_dims = if let Some(dims) = media.dimensions { + PixelDimensions { + x: dims.0, + y: dims.1, + } } else { - (300, 300) + PixelDimensions { x: 300, y: 300 } }; let Some(cache_type) = @@ -552,7 +555,7 @@ impl<'a, 'd> PostView<'a, 'd> { ui.ctx(), url, cache_type, - notedeck::ImageType::Content(Some((width, height))), + notedeck::ImageType::Content(Some(pixel_dims)), self.animation_mode, ); @@ -561,8 +564,7 @@ impl<'a, 'd> PostView<'a, 'd> { &mut self.draft.upload_errors, &mut to_remove, i, - width, - height, + pixel_dims, cur_state, ) } @@ -647,8 +649,7 @@ fn render_post_view_media( upload_errors: &mut Vec<String>, to_remove: &mut Vec<usize>, cur_index: usize, - width: u32, - height: u32, + pixel_dims: PixelDimensions, render_state: LatestImageTex, ) { match render_state { @@ -661,13 +662,10 @@ fn render_post_view_media( } LatestImageTex::Loaded(tex) => { let max_size = 300; - let size = if width > max_size || height > max_size { + let size = if pixel_dims.x > max_size || pixel_dims.y > max_size { PixelDimensions { x: 300, y: 300 } } else { - PixelDimensions { - x: width, - y: height, - } + pixel_dims } .to_points(ui.pixels_per_point()) .to_vec(); diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -45,7 +45,7 @@ impl<'a> EditProfileView<'a> { .id_salt(EditProfileView::scroll_id()) .stick_to_bottom(true) .show(ui, |ui| { - banner(ui, self.state.banner(), 188.0); + banner(ui, self.img_cache, self.jobs, self.state.banner(), 188.0); let padding = 24.0; notedeck_ui::padding(padding, ui, |ui| { diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -149,6 +149,8 @@ fn profile_body( ui.vertical(|ui| { let banner_resp = banner( ui, + note_context.img_cache, + note_context.jobs, profile .map(|p| p.record().profile()) .and_then(|p| p.and_then(|p| p.banner())), diff --git a/crates/notedeck_ui/src/note/media.rs b/crates/notedeck_ui/src/note/media.rs @@ -4,11 +4,11 @@ use egui::{ TextureHandle, Vec2, }; use notedeck::media::latest::ObfuscatedTexture; -use notedeck::MediaJobSender; use notedeck::{ fonts::get_font_size, show_one_error_message, tr, Images, Localization, MediaAction, MediaCacheType, NotedeckTextStyle, RenderableMedia, }; +use notedeck::{MediaJobSender, PointDimensions}; use crate::NoteOptions; use notedeck::media::images::ImageType; @@ -96,7 +96,9 @@ pub fn image_carousel( media_infos, i, img_cache, - ImageType::Content(Some((size.x as u32, size.y as u32))), + ImageType::Content(Some( + PointDimensions::from_vec(size).to_pixels(ui), + )), ); } }) @@ -142,7 +144,7 @@ pub fn render_media( ui, url, *media_type, - ImageType::Content(None), + ImageType::Content(Some(PointDimensions::from_vec(size).to_pixels(ui))), animation_mode, blur_type, size, diff --git a/crates/notedeck_ui/src/profile/mod.rs b/crates/notedeck_ui/src/profile/mod.rs @@ -8,8 +8,12 @@ pub mod preview; pub use picture::ProfilePic; pub use preview::ProfilePreview; -use egui::{load::TexturePoll, Label, RichText}; -use notedeck::{IsFollowing, NostrName, NotedeckTextStyle}; +use egui::{Label, RichText, TextureHandle}; +use notedeck::media::images::ImageType; +use notedeck::media::AnimationMode; +use notedeck::{ + Images, IsFollowing, MediaJobSender, NostrName, NotedeckTextStyle, PointDimensions, +}; use crate::{app_images, colors, widgets::styled_button_toggleable}; @@ -93,38 +97,64 @@ pub fn about_section_widget<'a>(profile: Option<&'a ProfileRecord<'a>>) -> impl } } -pub fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> { - // TODO: cache banner - if !banner_url.is_empty() { - let texture_load_res = - egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size()); - if let Ok(texture_poll) = texture_load_res { - match texture_poll { - TexturePoll::Pending { .. } => {} - TexturePoll::Ready { texture, .. } => return Some(texture), - } - } +/// Loads a banner texture using the shared media cache to prevent blocking. +#[profiling::function] +pub fn banner_texture<'a>( + ui: &mut egui::Ui, + cache: &'a mut Images, + jobs: &MediaJobSender, + banner_url: &str, + size: PointDimensions, +) -> Option<&'a TextureHandle> { + if banner_url.is_empty() { + return None; } - None + cache.latest_texture( + jobs, + ui, + banner_url, + ImageType::Content(Some(size.to_pixels(ui))), + AnimationMode::NoAnimation, + ) } -pub fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response { - ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| { +/// Renders a profile banner via the cached loader so we avoid egui_extras overhead. +#[profiling::function] +pub fn banner( + ui: &mut egui::Ui, + cache: &mut Images, + jobs: &MediaJobSender, + banner_url: Option<&str>, + height: f32, +) -> egui::Response { + let x = ui.available_size().x; + ui.add_sized([x, height], |ui: &mut egui::Ui| { banner_url - .and_then(|url| banner_texture(ui, url)) + .and_then(|url| banner_texture(ui, cache, jobs, url, PointDimensions { x, y: height })) .map(|texture| { + let size = texture.size_vec2(); + let aspect_ratio = if size.y == 0.0 { 1.0 } else { size.x / size.y }; + notedeck::media::images::aspect_fill( ui, egui::Sense::hover(), - texture.id, - texture.size.x / texture.size.y, + texture.id(), + aspect_ratio, ) }) - .unwrap_or_else(|| ui.label("")) + .unwrap_or_else(|| empty_banner(ui)) }) } +/// Draws an empty banner placeholder while the image loads or is missing. +fn empty_banner(ui: &mut egui::Ui) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, 0.0, ui.visuals().faint_bg_color); + response +} + pub fn follow_button(following: IsFollowing) -> impl egui::Widget + 'static { move |ui: &mut egui::Ui| -> egui::Response { let (bg_color, text) = match following { diff --git a/crates/notedeck_ui/src/profile/preview.rs b/crates/notedeck_ui/src/profile/preview.rs @@ -65,6 +65,8 @@ impl egui::Widget for ProfilePreview<'_, '_> { ui.vertical(|ui| { banner( ui, + self.cache, + self.jobs, self.profile.record().profile().and_then(|p| p.banner()), 80.0, );