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:
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,
);