From 9339a74abb7e0db540b5247da98d418c40cf3ae3 Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Thu, 13 Nov 2025 22:02:48 +0100 Subject: [PATCH 1/7] improve support and performance with very large images for thumbnail generation, preview tab and gallery view --- src/tab.rs | 643 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 601 insertions(+), 42 deletions(-) diff --git a/src/tab.rs b/src/tab.rs index 98f7738..653c0ad 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -49,6 +49,8 @@ use icu::{ use image::{DynamicImage, ImageDecoder, ImageReader}; use jxl_oxide::integration::JxlDecoder; use mime_guess::{Mime, mime}; +#[cfg(target_os = "linux")] +use procfs::Current; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use std::{ @@ -98,8 +100,47 @@ const MAX_SEARCH_RESULTS: usize = 200; //TODO: configurable thumbnail size? const THUMBNAIL_SIZE: u32 = (ICON_SIZE_GRID as u32) * (ICON_SCALE_MAX as u32); -pub static THUMB_SEMAPHORE: LazyLock = - LazyLock::new(|| tokio::sync::Semaphore::const_new(num_cpus::get())); +// Semaphore for normal-sized images (4 parallel workers) +pub static THUMB_SEMAPHORE_NORMAL: LazyLock = + LazyLock::new(|| tokio::sync::Semaphore::const_new(4)); + +// Semaphore for large images that would exceed per-worker memory limit (1 worker with full budget) +pub static THUMB_SEMAPHORE_LARGE: LazyLock = + LazyLock::new(|| tokio::sync::Semaphore::const_new(1)); + +// Memory management constants +/// Bytes per pixel in RGBA format (Red, Green, Blue, Alpha = 4 bytes) +const RGBA_BYTES_PER_PIXEL: u64 = 4; + +/// Overhead factor for image decoding operations (30% additional memory for decode buffers, +/// fragment allocations, and intermediate representations during image decoding) +const DECODE_OVERHEAD_FACTOR: f64 = 1.3; + +/// System memory reserve in MB to maintain for system stability (prevents thrashing) +/// Note: RAM checking is currently only available on Linux via procfs. +/// On Windows and macOS, only GPU buffer limits are enforced. +const SYSTEM_MEMORY_RESERVE_MB: u64 = 500; + +/// Maximum memory allocation for gallery image decoding in MB. +/// Gallery mode uses the full memory budget since only one image decodes at a time. +/// This matches the ThumbCfg max_mem_mb budget for consistency. +const GALLERY_MEMORY_LIMIT_MB: u64 = 2000; + +/// Atlas fragment/tile size in pixels. Large images are split into fragments of this size. +/// Must match the atlas SIZE constant in libcosmic/iced/wgpu/src/image/atlas.rs +const ATLAS_FRAGMENT_SIZE: u32 = 4096; + +/// Conservative GPU buffer size limit in MB. Each atlas fragment can be up to this size. +/// Based on wgpu device limits - most GPUs support at least 256MB buffers. +/// Reference: https://docs.rs/wgpu/latest/wgpu/struct.Limits.html#structfield.max_buffer_size +const MAX_GPU_BUFFER_MB: u64 = 256; + +/// Conversion factor: 1 MB = 1024 * 1024 bytes (binary megabyte, used for RAM calculations) +const MB_TO_BYTES: u64 = 1024 * 1024; + +/// Conversion factor: 1 MB = 1000 * 1000 bytes (decimal megabyte, used by image crate) +/// The image crate's memory limits use decimal MB, not binary MB. +const DECIMAL_MB_TO_BYTES: u64 = 1000 * 1000; pub(crate) static SORT_OPTION_FALLBACK: LazyLock> = LazyLock::new(|| { @@ -622,7 +663,7 @@ fn display_name_for_file(path: &Path, name: &str, get_from_gvfs: bool, is_deskto ); } else if get_from_gvfs { #[cfg(feature = "gvfs")] - return Item::display_name(glib::filename_display_name(path).as_str()) + return Item::display_name(glib::filename_display_name(path).as_str()); } Item::display_name(name) } @@ -1618,6 +1659,7 @@ pub enum Message { HighlightDeactivate(usize), HighlightActivate(usize), DirectorySize(PathBuf, DirSize), + ImageDecoded(PathBuf, u32, u32, Vec), } #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -1711,7 +1753,11 @@ impl ItemMetadata { #[derive(Debug)] pub enum ItemThumbnail { NotImage, - Image(widget::image::Handle, Option<(u32, u32)>), + Image( + widget::image::Handle, + Option<(u32, u32)>, + Option, + ), Svg(widget::svg::Handle), Text(widget::text_editor::Content), } @@ -1720,7 +1766,9 @@ impl Clone for ItemThumbnail { fn clone(&self) -> Self { match self { Self::NotImage => Self::NotImage, - Self::Image(handle, size_opt) => Self::Image(handle.clone(), *size_opt), + Self::Image(handle, size_opt, full_handle_opt) => { + Self::Image(handle.clone(), *size_opt, full_handle_opt.clone()) + } Self::Svg(handle) => Self::Svg(handle.clone()), // Content cannot be cloned simply Self::Text(content) => { @@ -1744,10 +1792,24 @@ impl ItemThumbnail { ThumbnailCacher::new(path, ThumbnailSize::from_pixel_size(thumbnail_size)); match thumbnail_cacher.as_ref() { Ok(cache) => match cache.get_cached_thumbnail() { - CachedThumbnail::Valid((path, size)) => { + CachedThumbnail::Valid((thumbnail_path, size)) => { + // Check original image dimensions even when loading cached thumbnail + // This prevents trying to load huge images in preview mode + let original_dims = match image::image_dimensions(path) { + Ok((width, height)) => Some((width, height)), + Err(_) => size.map(|s| (s.pixel_size(), s.pixel_size())), + }; + + // Create and cache the full-size handle for large images that need GPU tiling + // Images >4096 pixels get fragmented into multiple tiles for GPU upload + let full_handle = original_dims + .filter(|(w, h)| *w > 4096 || *h > 4096) + .map(|_| widget::image::Handle::from_path(path)); + return Self::Image( - widget::image::Handle::from_path(path), - size.map(|s| (s.pixel_size(), s.pixel_size())), + widget::image::Handle::from_path(thumbnail_path), + original_dims, + full_handle, ); } CachedThumbnail::Failed => { @@ -1787,6 +1849,47 @@ impl ItemThumbnail { let mut tried_supported_file = false; // First try built-in image thumbnailer if mime.type_() == mime::IMAGE && check_size("image", max_size_mb * 1000 * 1000) { + // Check for extremely large dimensions that would cause memory issues during decoding + // The GPU tiling system can handle large images, but we still need to decode them first + // Set a reasonable limit to prevent OOM during image decoding + const MAX_DIMENSION_FOR_DECODE: u32 = 65536; // 64K pixels is generous + let dimensions_ok = match image::image_dimensions(path) { + Ok((width, height)) => { + if width > MAX_DIMENSION_FOR_DECODE || height > MAX_DIMENSION_FOR_DECODE { + log::warn!( + "skipping thumbnail generation for {}: dimensions {}x{} exceed decode limit of {}", + path.display(), + width, + height, + MAX_DIMENSION_FOR_DECODE + ); + false + } else { + if width > 8192 || height > 8192 { + log::info!( + "Large image {}x{} detected, will use GPU tiling for display", + width, + height + ); + } + true + } + } + Err(err) => { + log::debug!( + "failed to read dimensions for {}: {}, will try decoding", + path.display(), + err + ); + true // If we can't read dimensions, try anyway + } + }; + + if !dimensions_ok { + // Skip this image entirely since it is too large to safely decode + return Self::NotImage; + } + tried_supported_file = true; let dyn_img: Option = match mime.subtype().as_str() { "jxl" => match File::open(path) { @@ -1840,10 +1943,21 @@ impl ItemThumbnail { }; if let Some(dyn_img) = dyn_img { + let (img_width, img_height) = (dyn_img.width(), dyn_img.height()); + let full_handle = if img_width > 4096 || img_height > 4096 { + Some(widget::image::Handle::from_path(path)) + } else { + None + }; + if let Ok(cacher) = thumbnail_cacher.as_ref() { match cacher.update_with_image(dyn_img) { - Ok(path) => { - return Self::Image(widget::image::Handle::from_path(path), None); + Ok(thumb_path) => { + return Self::Image( + widget::image::Handle::from_path(thumb_path), + Some((img_width, img_height)), + full_handle, + ); } Err(err) => { log::warn!("cacher failed to decode {}: {}", path.display(), err); @@ -1860,7 +1974,8 @@ impl ItemThumbnail { thumbnail.height(), thumbnail.into_raw(), ), - Some((dyn_img.width(), dyn_img.height())), + Some((img_width, img_height)), + full_handle, ); } } @@ -1988,6 +2103,7 @@ impl ItemThumbnail { image.into_raw(), ), None, + None, ), file, )); @@ -2073,12 +2189,9 @@ impl Item { .unwrap_or(&ItemThumbnail::NotImage) { ItemThumbnail::NotImage => icon, - ItemThumbnail::Image(handle, _) => { - if let Some(path) = self.path_opt() { - if self.mime.type_() == mime::IMAGE { - return widget::image(widget::image::Handle::from_path(path)).into(); - } - } + ItemThumbnail::Image(handle, _original_dims, _full_handle_opt) => { + // Preview pane: ALWAYS show thumbnail for instant, responsive UI + // Full resolution loading happens in gallery mode widget::image(handle.clone()).into() } ItemThumbnail::Svg(handle) => widget::svg(handle.clone()).into(), @@ -2480,6 +2593,221 @@ pub struct Tab { time_formatter: DateTimeFormatter, watch_drag: bool, window_id: Option, + decoding_images: std::collections::HashSet, + decoded_images: std::collections::HashMap, + decode_errors: std::collections::HashMap, +} + +fn get_image_dimensions(path: &Path) -> Option<(u32, u32)> { + match ImageReader::open(path) { + Ok(reader) => match reader.into_dimensions() { + Ok((width, height)) => { + log::debug!( + "Image dimensions: {}x{} for {}", + width, + height, + path.display() + ); + Some((width, height)) + } + Err(e) => { + log::warn!("Failed to get dimensions for {}: {}", path.display(), e); + None + } + }, + Err(e) => { + log::warn!("Failed to open image reader for {}: {}", path.display(), e); + None + } + } +} + +/// Check if there's sufficient memory to decode an image. +/// +/// This function performs two types of checks: +/// 1. System RAM availability (Linux only via procfs) +/// 2. GPU buffer limits (all platforms) +/// +/// Platform-specific behavior: +/// - Linux: Full RAM checking via /proc/meminfo + GPU checks +/// - Windows/macOS: GPU buffer checks only (RAM checking not yet implemented) +/// +fn check_memory_available(width: u32, height: u32) -> (bool, Option) { + if width == 0 || height == 0 { + let error_msg = format!( + "Invalid image dimensions: {}x{} (zero dimension)", + width, height + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + } + + let pixels = match (width as u64).checked_mul(height as u64) { + Some(p) => p, + None => { + let error_msg = format!( + "Image dimensions too large: {}x{} causes overflow in pixel calculation", + width, height + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + } + }; + + let bytes_needed = match pixels.checked_mul(RGBA_BYTES_PER_PIXEL) { + Some(b) => b, + None => { + let error_msg = format!( + "Image memory requirements overflow: {}x{} pixels requires more than {} bytes", + width, + height, + u64::MAX + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + } + }; + + // Add overhead for decode buffers, fragment allocations, and intermediate representations + let bytes_with_overhead = (bytes_needed as f64 * DECODE_OVERHEAD_FACTOR) as u64; + let mb_needed = bytes_with_overhead / MB_TO_BYTES; + + // Check system RAM availability (Linux only) + #[cfg(target_os = "linux")] + { + match procfs::Meminfo::current() { + Ok(meminfo) => { + // MemAvailable includes reclaimable cache and is the best estimate of + // actually available memory for new allocations + let available_kb = meminfo.mem_available.unwrap_or(0); + let available_bytes = available_kb * 1024; + + // Maintain system reserve to prevent thrashing and OOM killer + let min_reserve_bytes = SYSTEM_MEMORY_RESERVE_MB * MB_TO_BYTES; + let usable_bytes = available_bytes.saturating_sub(min_reserve_bytes); + + if bytes_with_overhead > usable_bytes { + let available_mb = available_bytes / MB_TO_BYTES; + let error_msg = format!( + "Insufficient memory: need {}MB, available {}MB. Try closing other applications.", + mb_needed, available_mb + ); + log::warn!("{}", error_msg); + return (false, Some(error_msg)); + } + } + Err(e) => { + log::warn!("Failed to read /proc/meminfo: {}. Skipping RAM check.", e); + // Graceful fallback: continue to GPU checks + } + } + } + + // Note: RAM checking not implemented for Windows/macOS + // These platforms will only validate against GPU buffer limits below + #[cfg(not(target_os = "linux"))] + { + log::debug!( + "RAM checking not available on this platform. Only GPU limits will be enforced." + ); + } + + // Check GPU fragment/atlas tile limits + // Large images are split into atlas fragments for GPU upload. + // Each fragment must fit within GPU buffer size limits. + let fragment_bytes = + (ATLAS_FRAGMENT_SIZE as u64) * (ATLAS_FRAGMENT_SIZE as u64) * RGBA_BYTES_PER_PIXEL; + let max_gpu_buffer_bytes = MAX_GPU_BUFFER_MB * MB_TO_BYTES; + + let fragments_x = (width + ATLAS_FRAGMENT_SIZE - 1) / ATLAS_FRAGMENT_SIZE; + let fragments_y = (height + ATLAS_FRAGMENT_SIZE - 1) / ATLAS_FRAGMENT_SIZE; + let fragment_count = fragments_x as u64 * fragments_y as u64; + + // Fragments are uploaded sequentially, so we only need one fragment buffer at a time. + // However, each individual fragment must fit within GPU buffer size limits. + if fragment_bytes > max_gpu_buffer_bytes { + let max_dimension = (MAX_GPU_BUFFER_MB * MB_TO_BYTES / RGBA_BYTES_PER_PIXEL) as f64; + let max_dimension = (max_dimension.sqrt() as u32).saturating_sub(100); // Add safety margin + + let error_msg = format!( + "Image too large for GPU: {}x{} pixels exceeds GPU buffer limits. \ + Maximum supported dimension is approximately {}x{} pixels.", + width, height, max_dimension, max_dimension + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + } + + log::debug!( + "Memory check passed: {}x{} image needs {}MB RAM, will use {} GPU fragment(s) of {}MB each", + width, + height, + mb_needed, + fragment_count, + fragment_bytes / MB_TO_BYTES + ); + + (true, None) +} + +/// Decode a large image asynchronously in a blocking thread pool. +/// +/// This function is used for gallery mode where full-resolution images need to be loaded. +/// It uses the full memory budget (GALLERY_MEMORY_LIMIT_MB) since only one image +/// decodes at a time in gallery mode. +/// +async fn decode_large_image(path: PathBuf) -> Option<(PathBuf, u32, u32, Vec)> { + // Decode image in blocking thread pool (CPU-intensive work should not block async runtime) + tokio::task::spawn_blocking(move || { + log::info!("Starting async decode of {}", path.display()); + + // Use ImageReader with explicit memory limits to avoid "Memory limit exceeded" errors + // Gallery mode uses the full memory budget since only one image decodes at a time + match image::ImageReader::open(&path) { + Ok(reader) => { + match reader.with_guessed_format() { + Ok(mut reader) => { + // Note: image crate uses decimal MB (1000^2), not binary MB (1024^2) + let mut limits = image::Limits::default(); + limits.max_alloc = Some(GALLERY_MEMORY_LIMIT_MB * DECIMAL_MB_TO_BYTES); + reader.limits(limits); + + match reader.decode() { + Ok(img) => { + let rgba = img.into_rgba8(); + let width = rgba.width(); + let height = rgba.height(); + let pixels = rgba.into_raw(); + + log::info!( + "Decoded {}x{} image: {}", + width, + height, + path.display() + ); + Some((path, width, height, pixels)) + } + Err(e) => { + log::warn!("Failed to decode {}: {}", path.display(), e); + None + } + } + } + Err(e) => { + log::warn!("Failed to guess format for {}: {}", path.display(), e); + None + } + } + } + Err(e) => { + log::warn!("Failed to open {}: {}", path.display(), e); + None + } + } + }) + .await + .ok() + .flatten() } async fn calculate_dir_size(path: &Path, controller: Controller) -> Result { @@ -2601,6 +2929,9 @@ impl Tab { time_formatter: time_formatter(config.military_time), watch_drag: true, window_id, + decoding_images: std::collections::HashSet::new(), + decoded_images: std::collections::HashMap::new(), + decode_errors: std::collections::HashMap::new(), } } @@ -2894,6 +3225,103 @@ impl Tab { last } + fn trigger_async_decode(&mut self) -> Vec { + let mut commands = Vec::new(); + + // Only trigger decode in gallery mode for the currently selected image + if !self.gallery { + return commands; + } + + if let Some(index) = self.select_focus { + if let Some(items) = &self.items_opt { + if let Some(item) = items.get(index) { + if let Some(ItemThumbnail::Image(_, _, _full_handle_opt)) = &item.thumbnail_opt + { + if let Some(path) = item.path_opt() { + self.decode_errors.remove(path); + + // Only decode if not already decoded or decoding + if !self.decoded_images.contains_key(path) + && !self.decoding_images.contains(path) + { + if let Some((width, height)) = get_image_dimensions(path) { + let (has_memory, error_opt) = + check_memory_available(width, height); + + if !has_memory { + // Insufficient memory --> try clearing cache + if !self.decoded_images.is_empty() { + log::info!( + "Insufficient memory, clearing {} cached images", + self.decoded_images.len() + ); + self.decoded_images.clear(); + + // Check again after clearing cache + let (has_memory_after_clear, error_opt_after) = + check_memory_available(width, height); + if !has_memory_after_clear { + if let Some(error_msg) = error_opt_after { + self.decode_errors + .insert(path.clone(), error_msg); + log::warn!( + "Cannot load {}: insufficient memory even after cache clear", + path.display() + ); + } + return commands; + } + log::info!( + "Memory available after cache clear, proceeding with decode" + ); + } else { + if let Some(error_msg) = error_opt { + self.decode_errors.insert(path.clone(), error_msg); + log::warn!( + "Cannot load {}: insufficient memory and cache is empty", + path.display() + ); + } + return commands; + } + } + + self.decoding_images.insert(path.clone()); + + let path_clone = path.clone(); + commands.push(Command::Iced( + cosmic::iced::Task::perform( + decode_large_image(path_clone), + |result| { + if let Some((path, width, height, pixels)) = result + { + Message::ImageDecoded( + path, width, height, pixels, + ) + } else { + Message::AutoScroll(None) + } + }, + ) + .into(), + )); + } else { + self.decode_errors.insert( + path.clone(), + "Failed to read image dimensions".to_string(), + ); + } + } + } + } + } + } + } + + commands + } + pub fn change_location(&mut self, location: &Location, history_i_opt: Option) { self.location = location.normalize(); self.location_ancestors = self.location.ancestors(); @@ -3305,6 +3733,10 @@ impl Tab { } Message::Gallery(gallery) => { self.gallery = gallery; + + if gallery { + commands.extend(self.trigger_async_decode()); + } } Message::GalleryPrevious | Message::GalleryNext => { let mut pos_opt = None; @@ -3332,6 +3764,8 @@ impl Tab { if let Some((row, col)) = pos_opt { // Should mod_shift be available? self.select_position(row, col, mod_shift); + + commands.extend(self.trigger_async_decode()); } if let Some(offset) = self.select_focus_scroll() { commands.push(Command::Iced( @@ -3814,7 +4248,7 @@ impl Tab { if item.location_opt.as_ref() == Some(&location) { let handle_opt = match &thumbnail { ItemThumbnail::NotImage => None, - ItemThumbnail::Image(handle, _) => Some(widget::icon::Handle { + ItemThumbnail::Image(handle, _, _) => Some(widget::icon::Handle { symbolic: false, data: widget::icon::Data::Image(handle.clone()), }), @@ -3836,6 +4270,16 @@ impl Tab { } } } + Message::ImageDecoded(path, width, height, pixels) => { + // Create handle from pre-decoded RGBA data (fast!) + let handle = widget::image::Handle::from_rgba(width, height, pixels); + + // Store decoded image handle + self.decoded_images.insert(path.clone(), handle); + + // Remove from decoding set + self.decoding_images.remove(&path); + } Message::ToggleSort(heading_option) => { if !matches!(self.location, Location::Search(..)) { let heading_sort = if self.sort_name == heading_option { @@ -4190,26 +4634,52 @@ impl Tab { .unwrap_or(&ItemThumbnail::NotImage) { ItemThumbnail::NotImage => {} - ItemThumbnail::Image(handle, _) => { - if let Some(path) = item.path_opt() { - element_opt = Some( - widget::container( - //TODO: use widget::image::viewer, when its zoom can be reset - widget::image(widget::image::Handle::from_path(path)), - ) - .center(Length::Fill) - .into(), - ); + ItemThumbnail::Image(handle, _original_dims, full_handle_opt) => { + // Determine which image to show based on async decode state + let (image_handle, is_loading, error_msg_opt) = if let Some(path) = + item.path_opt() + { + if let Some(error_msg) = self.decode_errors.get(path) { + (handle, false, Some(error_msg.clone())) + } else if let Some(decoded_handle) = self.decoded_images.get(path) { + // Full resolution ready --> use it + (decoded_handle, false, None) + } else if self.decoding_images.contains(path) { + // Currently decoding --> show thumbnail with loading indicator + (handle, true, None) + } else if let Some(full_handle) = full_handle_opt { + // Large image with tiled handle --> use it + (full_handle, false, None) + } else { + // Not decoded yet --> show thumbnail + (handle, false, None) + } } else { - element_opt = Some( - widget::container( - //TODO: use widget::image::viewer, when its zoom can be reset - widget::image(handle.clone()), - ) - .center(Length::Fill) - .into(), - ); - } + (handle, false, None) + }; + + let content: cosmic::Element<'_, Message> = + if let Some(error_msg) = error_msg_opt { + widget::column() + .push(widget::image(image_handle.clone())) + .push(widget::text(format!("⚠ {}", error_msg)).size(13)) + .padding(space_xs) + .align_x(cosmic::iced::Alignment::Center) + .into() + } else if is_loading { + widget::column() + .push(widget::image(image_handle.clone())) + .push(widget::text("Loading full resolution...").size(14)) + .padding(space_xs) + .align_x(cosmic::iced::Alignment::Center) + .into() + } else { + //TODO: use widget::image::viewer, when its zoom can be reset + widget::image(image_handle.clone()).into() + }; + + element_opt = + Some(widget::container(content).center(Length::Fill).into()); } ItemThumbnail::Svg(handle) => { element_opt = Some( @@ -5639,13 +6109,101 @@ impl Tab { let max_jobs = jobs; let max_mb = u64::from(self.thumb_config.max_mem_mb.get()); let max_size = u64::from(self.thumb_config.max_size_mb.get()); + + // Determine which queue to use based on image memory requirements. + // This routing ensures large images get dedicated worker with full memory budget, + // while normal images share 4-worker parallel queue for better throughput. + let (use_large_queue, effective_max_mb, effective_jobs) = if mime.type_() + == mime::IMAGE + { + log::debug!("Checking dimensions for image: {}", path.display()); + match image::image_dimensions(&path) { + Ok((width, height)) => { + if width == 0 || height == 0 { + log::warn!( + "Invalid image dimensions {}x{} for {}, using normal queue", + width, + height, + path.display() + ); + (false, max_mb, max_jobs) + } else if max_jobs == 0 { + log::error!( + "Configuration error: max_jobs is 0, using fallback (1 job)" + ); + (true, max_mb, 1) + } else { + let pixels = (width as u64).saturating_mul(height as u64); + let bytes_needed = pixels.saturating_mul(RGBA_BYTES_PER_PIXEL); + let mb_needed = bytes_needed / MB_TO_BYTES; + + // Calculate per-job limit with normal queue (shared memory budget) + // Use decimal MB conversion to match image crate's limit calculation + let total_bytes = max_mb.saturating_mul(DECIMAL_MB_TO_BYTES); + let per_job_limit = total_bytes / max_jobs as u64; + let per_job_limit_mb = per_job_limit / MB_TO_BYTES; + + log::debug!( + "Image {}x{} needs {}MB, per-job limit is {}MB (normal queue)", + width, + height, + mb_needed, + per_job_limit_mb + ); + + if bytes_needed > per_job_limit { + // Image exceeds per-job limit --> route to dedicated large image queue + log::info!( + "Image {}x{} needs {}MB (exceeds per-job limit of {}MB), \ + using large image queue (1 worker, {}MB full budget)", + width, + height, + mb_needed, + per_job_limit_mb, + max_mb + ); + (true, max_mb, 1) + } else { + // Image fits in per-job limit --> use normal parallel queue + log::debug!( + "Image {}x{} fits in normal queue ({}MB < {}MB limit)", + width, + height, + mb_needed, + per_job_limit_mb + ); + (false, max_mb, max_jobs) + } + } + } + Err(e) => { + // Cannot determine size, use normal queue + log::debug!( + "Failed to get dimensions for {}: {}, using normal queue", + path.display(), + e + ); + (false, max_mb, max_jobs) + } + } + } else { + // Non-image, use normal queue + (false, max_mb, max_jobs) + }; + subscriptions.push(Subscription::run_with_id( ("thumbnail", path.clone()), stream::channel(1, move |mut output| async move { let message = { let path = path.clone(); - _ = THUMB_SEMAPHORE.acquire().await; + // Acquire from appropriate semaphore based on image size + if use_large_queue { + _ = THUMB_SEMAPHORE_LARGE.acquire().await; + } else { + _ = THUMB_SEMAPHORE_NORMAL.acquire().await; + } + tokio::task::spawn_blocking(move || { let start = Instant::now(); let thumbnail = ItemThumbnail::new( @@ -5653,14 +6211,15 @@ impl Tab { metadata, mime, THUMBNAIL_SIZE, - max_mb, - max_jobs, + effective_max_mb, + effective_jobs, max_size, ); log::debug!( - "thumbnailed {} in {:?}", + "thumbnailed {} in {:?} (queue: {})", path.display(), - start.elapsed() + start.elapsed(), + if use_large_queue { "large" } else { "normal" } ); Message::Thumbnail(path, thumbnail) }) From 9b6ac00145efc144bb6e23e0717d7ddccacbb000 Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Sun, 16 Nov 2025 17:10:58 +0100 Subject: [PATCH 2/7] update libcosmic --- Cargo.lock | 75 ++++++++++++++++-------------------------------------- 1 file changed, 22 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f97b369..e07e45d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1427,7 +1427,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1448,7 +1448,7 @@ dependencies = [ [[package]] name = "cosmic-config-derive" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "quote", "syn 2.0.108", @@ -1528,15 +1528,15 @@ dependencies = [ [[package]] name = "cosmic-freedesktop-icons" -version = "0.3.0" -source = "git+https://github.com/pop-os/freedesktop-icons#8a05c322c482ff3c69cf34bacfee98907ac45307" +version = "0.4.0" +source = "git+https://github.com/pop-os/freedesktop-icons#689c60d428f46dc59316eafa22297e196afa4b15" dependencies = [ - "dirs 5.0.1", + "dirs 6.0.0", "ini_core", "memmap2 0.9.9", "thiserror 2.0.17", "tracing", - "xdg 2.5.2", + "xdg 3.0.0", ] [[package]] @@ -1588,7 +1588,7 @@ dependencies = [ [[package]] name = "cosmic-text" version = "0.15.0" -source = "git+https://github.com/pop-os/cosmic-text.git#8a7bc790e5283f9aa569c29f8142a7a1b14e2b80" +source = "git+https://github.com/pop-os/cosmic-text.git#9339446cfa9b7f0110094a97764dccc09cfa98a2" dependencies = [ "bitflags 2.10.0", "fontdb 0.23.0", @@ -1611,7 +1611,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "almost", "cosmic-config", @@ -3147,7 +3147,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.56.0", ] [[package]] @@ -3162,7 +3162,7 @@ dependencies = [ [[package]] name = "iced" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "dnd", "iced_accessibility", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "accesskit", "accesskit_winit", @@ -3189,7 +3189,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "bitflags 2.10.0", "bytes", @@ -3213,7 +3213,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "futures", "iced_core", @@ -3239,7 +3239,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -3261,7 +3261,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -3273,7 +3273,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "bytes", "cosmic-client-toolkit", @@ -3288,7 +3288,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "bytemuck", "cosmic-text", @@ -3304,7 +3304,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "as-raw-xcb-connection", "bitflags 2.10.0", @@ -3335,7 +3335,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -3354,7 +3354,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -4423,7 +4423,7 @@ checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "apply", "ashpd 0.12.0", @@ -8288,20 +8288,7 @@ dependencies = [ "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-strings", ] [[package]] @@ -8421,15 +8408,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -8439,15 +8417,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-sys" version = "0.45.0" From 0353009321114bbd3a07e696fb3e6a12ed13e1ae Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Sun, 16 Nov 2025 18:12:51 +0100 Subject: [PATCH 3/7] move large image handling out of tab and into new module large_image --- src/large_image.rs | 320 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/tab.rs | 315 +++++--------------------------------------- 3 files changed, 355 insertions(+), 281 deletions(-) create mode 100644 src/large_image.rs diff --git a/src/large_image.rs b/src/large_image.rs new file mode 100644 index 0000000..7510348 --- /dev/null +++ b/src/large_image.rs @@ -0,0 +1,320 @@ +use cosmic::widget; +use image::ImageReader; +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, +}; + +/// Bytes per pixel in RGBA format (Red, Green, Blue, Alpha = 4 bytes) +pub const RGBA_BYTES_PER_PIXEL: u64 = 4; + +/// Overhead factor for image decoding operations (30% additional memory for decode buffers, +/// fragment allocations, and intermediate representations during image decoding) +const DECODE_OVERHEAD_FACTOR: f64 = 1.3; + +/// System memory reserve in MB to maintain for system stability (prevents thrashing) +/// Note: RAM checking is currently only available on Linux via procfs. +/// On Windows and macOS, only GPU buffer limits are enforced. +const SYSTEM_MEMORY_RESERVE_MB: u64 = 500; + +/// Maximum memory allocation for gallery image decoding in MB. +/// Gallery mode uses the full memory budget since only one image decodes at a time. +/// This matches the ThumbCfg max_mem_mb budget for consistency. +const GALLERY_MEMORY_LIMIT_MB: u64 = 2000; + +/// Threshold for considering an image "large" requiring GPU tiling +/// Atlas fragment/tile size in pixels. Large images are split into fragments of this size. +/// Must match the atlas SIZE constant in libcosmic/iced/wgpu/src/image/atlas.rs +pub const ATLAS_FRAGMENT_SIZE: u32 = 4096; + +/// Conservative GPU buffer size limit in MB. Each atlas fragment can be up to this size. +/// Based on wgpu device limits - most GPUs support at least 256MB buffers. +/// Reference: https://docs.rs/wgpu/latest/wgpu/struct.Limits.html#structfield.max_buffer_size +const MAX_GPU_BUFFER_MB: u64 = 256; + +/// Conversion factor: 1 MB = 1024 * 1024 bytes (binary megabyte, used for RAM calculations) +pub const MB_TO_BYTES: u64 = 1024 * 1024; + +/// Conversion factor: 1 MB = 1000 * 1000 bytes (decimal megabyte, used by image crate) +/// The image crate's memory limits use decimal MB, not binary MB. +pub const DECIMAL_MB_TO_BYTES: u64 = 1000 * 1000; + +/// Maximum dimension for image decoding +pub const MAX_DIMENSION_FOR_DECODE: u32 = 65536; + +/// Get the dimensions of an image without fully decoding it +pub fn get_image_dimensions(path: &Path) -> Option<(u32, u32)> { + match ImageReader::open(path) { + Ok(reader) => match reader.into_dimensions() { + Ok((width, height)) => { + log::debug!( + "Image dimensions: {}x{} for {}", + width, + height, + path.display() + ); + Some((width, height)) + } + Err(e) => { + log::warn!("Failed to get dimensions for {}: {}", path.display(), e); + None + } + }, + Err(e) => { + log::warn!("Failed to open image reader for {}: {}", path.display(), e); + None + } + } +} + +/// Check if there's sufficient memory to decode an image. +/// +/// This function performs two types of checks: +/// 1. System RAM availability (Linux only via procfs) +/// 2. GPU buffer limits (all platforms) +/// +/// Platform-specific behavior: +/// - Linux: Full RAM checking via /proc/meminfo + GPU checks +/// - Windows/macOS: GPU buffer checks only (RAM checking not yet implemented) +/// +/// Returns: (has_memory, error_message) +pub fn check_memory_available(width: u32, height: u32) -> (bool, Option) { + if width == 0 || height == 0 { + let error_msg = format!( + "Invalid image dimensions: {}x{} (zero dimension)", + width, height + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + } + + let pixels = match (width as u64).checked_mul(height as u64) { + Some(p) => p, + None => { + let error_msg = format!( + "Image dimensions too large: {}x{} causes overflow in pixel calculation", + width, height + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + } + }; + + let bytes_needed = match pixels.checked_mul(RGBA_BYTES_PER_PIXEL) { + Some(b) => b, + None => { + let error_msg = format!( + "Image memory requirements overflow: {}x{} pixels requires more than {} bytes", + width, + height, + u64::MAX + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + } + }; + + // Add overhead for decode buffers, fragment allocations, and intermediate representations + let bytes_with_overhead = (bytes_needed as f64 * DECODE_OVERHEAD_FACTOR) as u64; + let mb_needed = bytes_with_overhead / MB_TO_BYTES; + + // Check system RAM availability (Linux only) + #[cfg(target_os = "linux")] + { + use procfs::Current; + match procfs::Meminfo::current() { + Ok(meminfo) => { + // MemAvailable includes reclaimable cache and is the best estimate of + // actually available memory for new allocations + let available_kb = meminfo.mem_available.unwrap_or(0); + let available_bytes = available_kb * 1024; + + // Maintain system reserve to prevent thrashing and OOM killer + let min_reserve_bytes = SYSTEM_MEMORY_RESERVE_MB * MB_TO_BYTES; + let usable_bytes = available_bytes.saturating_sub(min_reserve_bytes); + + if bytes_with_overhead > usable_bytes { + let available_mb = available_bytes / MB_TO_BYTES; + let error_msg = format!( + "Insufficient memory: need {}MB, available {}MB. Try closing other applications.", + mb_needed, available_mb + ); + log::warn!("{}", error_msg); + return (false, Some(error_msg)); + } + } + Err(e) => { + log::warn!("Failed to read /proc/meminfo: {}. Skipping RAM check.", e); + // Graceful fallback: continue to GPU checks + } + } + } + + // Note: RAM checking not implemented for Windows/macOS + // These platforms will only validate against GPU buffer limits below + #[cfg(not(target_os = "linux"))] + { + log::debug!( + "RAM checking not available on this platform. Only GPU limits will be enforced." + ); + } + + // Check GPU fragment/atlas tile limits + // Large images are split into atlas fragments for GPU upload. + // Each fragment must fit within GPU buffer size limits. + let fragment_bytes = + (ATLAS_FRAGMENT_SIZE as u64) * (ATLAS_FRAGMENT_SIZE as u64) * RGBA_BYTES_PER_PIXEL; + let max_gpu_buffer_bytes = MAX_GPU_BUFFER_MB * MB_TO_BYTES; + + let fragments_x = (width + ATLAS_FRAGMENT_SIZE - 1) / ATLAS_FRAGMENT_SIZE; + let fragments_y = (height + ATLAS_FRAGMENT_SIZE - 1) / ATLAS_FRAGMENT_SIZE; + let fragment_count = fragments_x as u64 * fragments_y as u64; + + // Fragments are uploaded sequentially, so we only need one fragment buffer at a time. + // However, each individual fragment must fit within GPU buffer size limits. + if fragment_bytes > max_gpu_buffer_bytes { + let max_dimension = (MAX_GPU_BUFFER_MB * MB_TO_BYTES / RGBA_BYTES_PER_PIXEL) as f64; + let max_dimension = (max_dimension.sqrt() as u32).saturating_sub(100); // Add safety margin + + let error_msg = format!( + "Image too large for GPU: {}x{} pixels exceeds GPU buffer limits. \ + Maximum supported dimension is approximately {}x{} pixels.", + width, height, max_dimension, max_dimension + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + } + + log::debug!( + "Memory check passed: {}x{} image needs {}MB RAM, will use {} GPU fragment(s) of {}MB each", + width, + height, + mb_needed, + fragment_count, + fragment_bytes / MB_TO_BYTES + ); + + (true, None) +} + +/// Decode a large image asynchronously in a blocking thread pool. +/// +/// This function is used for gallery mode where full-resolution images need to be loaded. +/// It uses the full memory budget (GALLERY_MEMORY_LIMIT_MB) since only one image +/// decodes at a time in gallery mode. +pub async fn decode_large_image(path: PathBuf) -> Option<(PathBuf, u32, u32, Vec)> { + // Decode image in blocking thread pool (CPU-intensive work should not block) + tokio::task::spawn_blocking(move || { + log::info!("Starting async decode of {}", path.display()); + + // Use ImageReader with explicit memory limits to avoid "Memory limit exceeded" errors + // Gallery mode uses the full memory budget since only one image decodes at a time + match image::ImageReader::open(&path) { + Ok(reader) => { + match reader.with_guessed_format() { + Ok(mut reader) => { + // Note: image crate uses decimal MB (1000^2), not binary MB (1024^2) + let mut limits = image::Limits::default(); + limits.max_alloc = Some(GALLERY_MEMORY_LIMIT_MB * DECIMAL_MB_TO_BYTES); + reader.limits(limits); + + match reader.decode() { + Ok(img) => { + let rgba = img.into_rgba8(); + let width = rgba.width(); + let height = rgba.height(); + let pixels = rgba.into_raw(); + + log::info!( + "Decoded {}x{} image: {}", + width, + height, + path.display() + ); + Some((path, width, height, pixels)) + } + Err(e) => { + log::warn!("Failed to decode {}: {}", path.display(), e); + None + } + } + } + Err(e) => { + log::warn!("Failed to guess format for {}: {}", path.display(), e); + None + } + } + } + Err(e) => { + log::warn!("Failed to open {}: {}", path.display(), e); + None + } + } + }) + .await + .ok() + .flatten() +} + + +/// Manages state and operations for large image decoding in gallery mode +#[derive(Debug, Default)] +pub struct LargeImageManager { + /// Paths of images currently being decoded + decoding_images: HashSet, + /// Cache of decoded image handles + decoded_images: HashMap, + /// Errors encountered during decoding + decode_errors: HashMap, +} + +impl LargeImageManager { + pub fn new() -> Self { + Self::default() + } + + pub fn is_decoding(&self, path: &Path) -> bool { + self.decoding_images.contains(path) + } + + pub fn get_decoded(&self, path: &Path) -> Option<&widget::image::Handle> { + self.decoded_images.get(path) + } + + pub fn get_error(&self, path: &Path) -> Option<&String> { + self.decode_errors.get(path) + } + + pub fn mark_decoding(&mut self, path: PathBuf) { + self.decoding_images.insert(path); + } + + pub fn store_decoded(&mut self, path: PathBuf, handle: widget::image::Handle) { + self.decoded_images.insert(path.clone(), handle); + self.decoding_images.remove(&path); + } + + pub fn store_error(&mut self, path: PathBuf, error: String) { + self.decode_errors.insert(path, error); + } + + pub fn clear_error(&mut self, path: &Path) { + self.decode_errors.remove(path); + } + + pub fn clear_cache(&mut self) { + log::info!( + "Clearing {} cached images from large image manager", + self.decoded_images.len() + ); + self.decoded_images.clear(); + } + + pub fn cache_size(&self) -> usize { + self.decoded_images.len() + } + + pub fn cache_is_empty(&self) -> bool { + self.decoded_images.is_empty() + } +} diff --git a/src/lib.rs b/src/lib.rs index 6ffbe2e..e2bd675 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ use config::Config; pub mod config; pub mod dialog; mod key_bind; +pub(crate) mod large_image; mod localize; mod menu; mod mime_app; diff --git a/src/tab.rs b/src/tab.rs index 653c0ad..b97aeda 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -49,8 +49,6 @@ use icu::{ use image::{DynamicImage, ImageDecoder, ImageReader}; use jxl_oxide::integration::JxlDecoder; use mime_guess::{Mime, mime}; -#[cfg(target_os = "linux")] -use procfs::Current; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use std::{ @@ -81,6 +79,11 @@ use crate::{ config::{DesktopConfig, ICON_SCALE_MAX, ICON_SIZE_GRID, IconSizes, TabConfig, ThumbCfg}, dialog::DialogKind, fl, + large_image::{ + DECIMAL_MB_TO_BYTES, LargeImageManager, MAX_DIMENSION_FOR_DECODE, ATLAS_FRAGMENT_SIZE, + MB_TO_BYTES, RGBA_BYTES_PER_PIXEL, check_memory_available, decode_large_image, + get_image_dimensions, + }, localize::{LANGUAGE_SORTER, LOCALE}, menu, mime_app, mime_icon::{mime_for_path, mime_icon}, @@ -108,40 +111,6 @@ pub static THUMB_SEMAPHORE_NORMAL: LazyLock = pub static THUMB_SEMAPHORE_LARGE: LazyLock = LazyLock::new(|| tokio::sync::Semaphore::const_new(1)); -// Memory management constants -/// Bytes per pixel in RGBA format (Red, Green, Blue, Alpha = 4 bytes) -const RGBA_BYTES_PER_PIXEL: u64 = 4; - -/// Overhead factor for image decoding operations (30% additional memory for decode buffers, -/// fragment allocations, and intermediate representations during image decoding) -const DECODE_OVERHEAD_FACTOR: f64 = 1.3; - -/// System memory reserve in MB to maintain for system stability (prevents thrashing) -/// Note: RAM checking is currently only available on Linux via procfs. -/// On Windows and macOS, only GPU buffer limits are enforced. -const SYSTEM_MEMORY_RESERVE_MB: u64 = 500; - -/// Maximum memory allocation for gallery image decoding in MB. -/// Gallery mode uses the full memory budget since only one image decodes at a time. -/// This matches the ThumbCfg max_mem_mb budget for consistency. -const GALLERY_MEMORY_LIMIT_MB: u64 = 2000; - -/// Atlas fragment/tile size in pixels. Large images are split into fragments of this size. -/// Must match the atlas SIZE constant in libcosmic/iced/wgpu/src/image/atlas.rs -const ATLAS_FRAGMENT_SIZE: u32 = 4096; - -/// Conservative GPU buffer size limit in MB. Each atlas fragment can be up to this size. -/// Based on wgpu device limits - most GPUs support at least 256MB buffers. -/// Reference: https://docs.rs/wgpu/latest/wgpu/struct.Limits.html#structfield.max_buffer_size -const MAX_GPU_BUFFER_MB: u64 = 256; - -/// Conversion factor: 1 MB = 1024 * 1024 bytes (binary megabyte, used for RAM calculations) -const MB_TO_BYTES: u64 = 1024 * 1024; - -/// Conversion factor: 1 MB = 1000 * 1000 bytes (decimal megabyte, used by image crate) -/// The image crate's memory limits use decimal MB, not binary MB. -const DECIMAL_MB_TO_BYTES: u64 = 1000 * 1000; - pub(crate) static SORT_OPTION_FALLBACK: LazyLock> = LazyLock::new(|| { FxHashMap::from_iter(dirs::download_dir().into_iter().map(|dir| { @@ -1803,7 +1772,7 @@ impl ItemThumbnail { // Create and cache the full-size handle for large images that need GPU tiling // Images >4096 pixels get fragmented into multiple tiles for GPU upload let full_handle = original_dims - .filter(|(w, h)| *w > 4096 || *h > 4096) + .filter(|(w, h)| *w > ATLAS_FRAGMENT_SIZE || *h > ATLAS_FRAGMENT_SIZE) .map(|_| widget::image::Handle::from_path(path)); return Self::Image( @@ -1852,7 +1821,6 @@ impl ItemThumbnail { // Check for extremely large dimensions that would cause memory issues during decoding // The GPU tiling system can handle large images, but we still need to decode them first // Set a reasonable limit to prevent OOM during image decoding - const MAX_DIMENSION_FOR_DECODE: u32 = 65536; // 64K pixels is generous let dimensions_ok = match image::image_dimensions(path) { Ok((width, height)) => { if width > MAX_DIMENSION_FOR_DECODE || height > MAX_DIMENSION_FOR_DECODE { @@ -1865,7 +1833,7 @@ impl ItemThumbnail { ); false } else { - if width > 8192 || height > 8192 { + if width > ATLAS_FRAGMENT_SIZE || height > ATLAS_FRAGMENT_SIZE { log::info!( "Large image {}x{} detected, will use GPU tiling for display", width, @@ -1944,11 +1912,12 @@ impl ItemThumbnail { if let Some(dyn_img) = dyn_img { let (img_width, img_height) = (dyn_img.width(), dyn_img.height()); - let full_handle = if img_width > 4096 || img_height > 4096 { - Some(widget::image::Handle::from_path(path)) - } else { - None - }; + let full_handle = + if img_width > ATLAS_FRAGMENT_SIZE || img_height > ATLAS_FRAGMENT_SIZE { + Some(widget::image::Handle::from_path(path)) + } else { + None + }; if let Ok(cacher) = thumbnail_cacher.as_ref() { match cacher.update_with_image(dyn_img) { @@ -2593,221 +2562,7 @@ pub struct Tab { time_formatter: DateTimeFormatter, watch_drag: bool, window_id: Option, - decoding_images: std::collections::HashSet, - decoded_images: std::collections::HashMap, - decode_errors: std::collections::HashMap, -} - -fn get_image_dimensions(path: &Path) -> Option<(u32, u32)> { - match ImageReader::open(path) { - Ok(reader) => match reader.into_dimensions() { - Ok((width, height)) => { - log::debug!( - "Image dimensions: {}x{} for {}", - width, - height, - path.display() - ); - Some((width, height)) - } - Err(e) => { - log::warn!("Failed to get dimensions for {}: {}", path.display(), e); - None - } - }, - Err(e) => { - log::warn!("Failed to open image reader for {}: {}", path.display(), e); - None - } - } -} - -/// Check if there's sufficient memory to decode an image. -/// -/// This function performs two types of checks: -/// 1. System RAM availability (Linux only via procfs) -/// 2. GPU buffer limits (all platforms) -/// -/// Platform-specific behavior: -/// - Linux: Full RAM checking via /proc/meminfo + GPU checks -/// - Windows/macOS: GPU buffer checks only (RAM checking not yet implemented) -/// -fn check_memory_available(width: u32, height: u32) -> (bool, Option) { - if width == 0 || height == 0 { - let error_msg = format!( - "Invalid image dimensions: {}x{} (zero dimension)", - width, height - ); - log::error!("{}", error_msg); - return (false, Some(error_msg)); - } - - let pixels = match (width as u64).checked_mul(height as u64) { - Some(p) => p, - None => { - let error_msg = format!( - "Image dimensions too large: {}x{} causes overflow in pixel calculation", - width, height - ); - log::error!("{}", error_msg); - return (false, Some(error_msg)); - } - }; - - let bytes_needed = match pixels.checked_mul(RGBA_BYTES_PER_PIXEL) { - Some(b) => b, - None => { - let error_msg = format!( - "Image memory requirements overflow: {}x{} pixels requires more than {} bytes", - width, - height, - u64::MAX - ); - log::error!("{}", error_msg); - return (false, Some(error_msg)); - } - }; - - // Add overhead for decode buffers, fragment allocations, and intermediate representations - let bytes_with_overhead = (bytes_needed as f64 * DECODE_OVERHEAD_FACTOR) as u64; - let mb_needed = bytes_with_overhead / MB_TO_BYTES; - - // Check system RAM availability (Linux only) - #[cfg(target_os = "linux")] - { - match procfs::Meminfo::current() { - Ok(meminfo) => { - // MemAvailable includes reclaimable cache and is the best estimate of - // actually available memory for new allocations - let available_kb = meminfo.mem_available.unwrap_or(0); - let available_bytes = available_kb * 1024; - - // Maintain system reserve to prevent thrashing and OOM killer - let min_reserve_bytes = SYSTEM_MEMORY_RESERVE_MB * MB_TO_BYTES; - let usable_bytes = available_bytes.saturating_sub(min_reserve_bytes); - - if bytes_with_overhead > usable_bytes { - let available_mb = available_bytes / MB_TO_BYTES; - let error_msg = format!( - "Insufficient memory: need {}MB, available {}MB. Try closing other applications.", - mb_needed, available_mb - ); - log::warn!("{}", error_msg); - return (false, Some(error_msg)); - } - } - Err(e) => { - log::warn!("Failed to read /proc/meminfo: {}. Skipping RAM check.", e); - // Graceful fallback: continue to GPU checks - } - } - } - - // Note: RAM checking not implemented for Windows/macOS - // These platforms will only validate against GPU buffer limits below - #[cfg(not(target_os = "linux"))] - { - log::debug!( - "RAM checking not available on this platform. Only GPU limits will be enforced." - ); - } - - // Check GPU fragment/atlas tile limits - // Large images are split into atlas fragments for GPU upload. - // Each fragment must fit within GPU buffer size limits. - let fragment_bytes = - (ATLAS_FRAGMENT_SIZE as u64) * (ATLAS_FRAGMENT_SIZE as u64) * RGBA_BYTES_PER_PIXEL; - let max_gpu_buffer_bytes = MAX_GPU_BUFFER_MB * MB_TO_BYTES; - - let fragments_x = (width + ATLAS_FRAGMENT_SIZE - 1) / ATLAS_FRAGMENT_SIZE; - let fragments_y = (height + ATLAS_FRAGMENT_SIZE - 1) / ATLAS_FRAGMENT_SIZE; - let fragment_count = fragments_x as u64 * fragments_y as u64; - - // Fragments are uploaded sequentially, so we only need one fragment buffer at a time. - // However, each individual fragment must fit within GPU buffer size limits. - if fragment_bytes > max_gpu_buffer_bytes { - let max_dimension = (MAX_GPU_BUFFER_MB * MB_TO_BYTES / RGBA_BYTES_PER_PIXEL) as f64; - let max_dimension = (max_dimension.sqrt() as u32).saturating_sub(100); // Add safety margin - - let error_msg = format!( - "Image too large for GPU: {}x{} pixels exceeds GPU buffer limits. \ - Maximum supported dimension is approximately {}x{} pixels.", - width, height, max_dimension, max_dimension - ); - log::error!("{}", error_msg); - return (false, Some(error_msg)); - } - - log::debug!( - "Memory check passed: {}x{} image needs {}MB RAM, will use {} GPU fragment(s) of {}MB each", - width, - height, - mb_needed, - fragment_count, - fragment_bytes / MB_TO_BYTES - ); - - (true, None) -} - -/// Decode a large image asynchronously in a blocking thread pool. -/// -/// This function is used for gallery mode where full-resolution images need to be loaded. -/// It uses the full memory budget (GALLERY_MEMORY_LIMIT_MB) since only one image -/// decodes at a time in gallery mode. -/// -async fn decode_large_image(path: PathBuf) -> Option<(PathBuf, u32, u32, Vec)> { - // Decode image in blocking thread pool (CPU-intensive work should not block async runtime) - tokio::task::spawn_blocking(move || { - log::info!("Starting async decode of {}", path.display()); - - // Use ImageReader with explicit memory limits to avoid "Memory limit exceeded" errors - // Gallery mode uses the full memory budget since only one image decodes at a time - match image::ImageReader::open(&path) { - Ok(reader) => { - match reader.with_guessed_format() { - Ok(mut reader) => { - // Note: image crate uses decimal MB (1000^2), not binary MB (1024^2) - let mut limits = image::Limits::default(); - limits.max_alloc = Some(GALLERY_MEMORY_LIMIT_MB * DECIMAL_MB_TO_BYTES); - reader.limits(limits); - - match reader.decode() { - Ok(img) => { - let rgba = img.into_rgba8(); - let width = rgba.width(); - let height = rgba.height(); - let pixels = rgba.into_raw(); - - log::info!( - "Decoded {}x{} image: {}", - width, - height, - path.display() - ); - Some((path, width, height, pixels)) - } - Err(e) => { - log::warn!("Failed to decode {}: {}", path.display(), e); - None - } - } - } - Err(e) => { - log::warn!("Failed to guess format for {}: {}", path.display(), e); - None - } - } - } - Err(e) => { - log::warn!("Failed to open {}: {}", path.display(), e); - None - } - } - }) - .await - .ok() - .flatten() + large_image_manager: LargeImageManager, } async fn calculate_dir_size(path: &Path, controller: Controller) -> Result { @@ -2929,9 +2684,7 @@ impl Tab { time_formatter: time_formatter(config.military_time), watch_drag: true, window_id, - decoding_images: std::collections::HashSet::new(), - decoded_images: std::collections::HashMap::new(), - decode_errors: std::collections::HashMap::new(), + large_image_manager: LargeImageManager::new(), } } @@ -3239,11 +2992,11 @@ impl Tab { if let Some(ItemThumbnail::Image(_, _, _full_handle_opt)) = &item.thumbnail_opt { if let Some(path) = item.path_opt() { - self.decode_errors.remove(path); + self.large_image_manager.clear_error(path); // Only decode if not already decoded or decoding - if !self.decoded_images.contains_key(path) - && !self.decoding_images.contains(path) + if self.large_image_manager.get_decoded(path).is_none() + && !self.large_image_manager.is_decoding(path) { if let Some((width, height)) = get_image_dimensions(path) { let (has_memory, error_opt) = @@ -3251,20 +3004,20 @@ impl Tab { if !has_memory { // Insufficient memory --> try clearing cache - if !self.decoded_images.is_empty() { + if !self.large_image_manager.cache_is_empty() { log::info!( "Insufficient memory, clearing {} cached images", - self.decoded_images.len() + self.large_image_manager.cache_size() ); - self.decoded_images.clear(); + self.large_image_manager.clear_cache(); // Check again after clearing cache let (has_memory_after_clear, error_opt_after) = check_memory_available(width, height); if !has_memory_after_clear { if let Some(error_msg) = error_opt_after { - self.decode_errors - .insert(path.clone(), error_msg); + self.large_image_manager + .store_error(path.clone(), error_msg); log::warn!( "Cannot load {}: insufficient memory even after cache clear", path.display() @@ -3277,7 +3030,8 @@ impl Tab { ); } else { if let Some(error_msg) = error_opt { - self.decode_errors.insert(path.clone(), error_msg); + self.large_image_manager + .store_error(path.clone(), error_msg); log::warn!( "Cannot load {}: insufficient memory and cache is empty", path.display() @@ -3287,7 +3041,7 @@ impl Tab { } } - self.decoding_images.insert(path.clone()); + self.large_image_manager.mark_decoding(path.clone()); let path_clone = path.clone(); commands.push(Command::Iced( @@ -3307,7 +3061,7 @@ impl Tab { .into(), )); } else { - self.decode_errors.insert( + self.large_image_manager.store_error( path.clone(), "Failed to read image dimensions".to_string(), ); @@ -4274,11 +4028,8 @@ impl Tab { // Create handle from pre-decoded RGBA data (fast!) let handle = widget::image::Handle::from_rgba(width, height, pixels); - // Store decoded image handle - self.decoded_images.insert(path.clone(), handle); - - // Remove from decoding set - self.decoding_images.remove(&path); + // Store decoded image handle and remove from decoding set + self.large_image_manager.store_decoded(path, handle); } Message::ToggleSort(heading_option) => { if !matches!(self.location, Location::Search(..)) { @@ -4639,12 +4390,14 @@ impl Tab { let (image_handle, is_loading, error_msg_opt) = if let Some(path) = item.path_opt() { - if let Some(error_msg) = self.decode_errors.get(path) { + if let Some(error_msg) = self.large_image_manager.get_error(path) { (handle, false, Some(error_msg.clone())) - } else if let Some(decoded_handle) = self.decoded_images.get(path) { + } else if let Some(decoded_handle) = + self.large_image_manager.get_decoded(path) + { // Full resolution ready --> use it (decoded_handle, false, None) - } else if self.decoding_images.contains(path) { + } else if self.large_image_manager.is_decoding(path) { // Currently decoding --> show thumbnail with loading indicator (handle, true, None) } else if let Some(full_handle) = full_handle_opt { From 006b69d98b0b5b11ae82e140e407d5d679100bc8 Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Sun, 16 Nov 2025 18:40:55 +0100 Subject: [PATCH 4/7] refactor try_decode_image --- src/tab.rs | 187 +++++++++++++++++++++++++++++------------------------ 1 file changed, 104 insertions(+), 83 deletions(-) diff --git a/src/tab.rs b/src/tab.rs index b97aeda..944eb13 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -2979,101 +2979,122 @@ impl Tab { } fn trigger_async_decode(&mut self) -> Vec { - let mut commands = Vec::new(); - // Only trigger decode in gallery mode for the currently selected image if !self.gallery { - return commands; + return Vec::new(); } - if let Some(index) = self.select_focus { - if let Some(items) = &self.items_opt { - if let Some(item) = items.get(index) { - if let Some(ItemThumbnail::Image(_, _, _full_handle_opt)) = &item.thumbnail_opt - { - if let Some(path) = item.path_opt() { - self.large_image_manager.clear_error(path); + let Some(index) = self.select_focus else { + return Vec::new(); + }; - // Only decode if not already decoded or decoding - if self.large_image_manager.get_decoded(path).is_none() - && !self.large_image_manager.is_decoding(path) - { - if let Some((width, height)) = get_image_dimensions(path) { - let (has_memory, error_opt) = - check_memory_available(width, height); + let Some(items) = &self.items_opt else { + return Vec::new(); + }; - if !has_memory { - // Insufficient memory --> try clearing cache - if !self.large_image_manager.cache_is_empty() { - log::info!( - "Insufficient memory, clearing {} cached images", - self.large_image_manager.cache_size() - ); - self.large_image_manager.clear_cache(); + let Some(item) = items.get(index) else { + return Vec::new(); + }; - // Check again after clearing cache - let (has_memory_after_clear, error_opt_after) = - check_memory_available(width, height); - if !has_memory_after_clear { - if let Some(error_msg) = error_opt_after { - self.large_image_manager - .store_error(path.clone(), error_msg); - log::warn!( - "Cannot load {}: insufficient memory even after cache clear", - path.display() - ); - } - return commands; - } - log::info!( - "Memory available after cache clear, proceeding with decode" - ); - } else { - if let Some(error_msg) = error_opt { - self.large_image_manager - .store_error(path.clone(), error_msg); - log::warn!( - "Cannot load {}: insufficient memory and cache is empty", - path.display() - ); - } - return commands; - } - } + let Some(ItemThumbnail::Image(_, _, _)) = &item.thumbnail_opt else { + return Vec::new(); + }; - self.large_image_manager.mark_decoding(path.clone()); + let Some(path) = item.path_opt() else { + return Vec::new(); + }; - let path_clone = path.clone(); - commands.push(Command::Iced( - cosmic::iced::Task::perform( - decode_large_image(path_clone), - |result| { - if let Some((path, width, height, pixels)) = result - { - Message::ImageDecoded( - path, width, height, pixels, - ) - } else { - Message::AutoScroll(None) - } - }, - ) - .into(), - )); - } else { - self.large_image_manager.store_error( - path.clone(), - "Failed to read image dimensions".to_string(), - ); - } - } - } - } - } + // Clone path to avoid borrow checker issues + let path = path.to_path_buf(); + + // Clear any previous errors for this image + self.large_image_manager.clear_error(&path); + + // Check if image is already decoded or currently decoding + if self.large_image_manager.get_decoded(&path).is_some() + || self.large_image_manager.is_decoding(&path) + { + return Vec::new(); + } + + // Try to decode the image + self.try_decode_image(&path) + } + + /// Attempt to decode a large image, handling memory constraints + fn try_decode_image(&mut self, path: &PathBuf) -> Vec { + let Some((width, height)) = get_image_dimensions(path) else { + self.large_image_manager.store_error( + path.clone(), + "Failed to read image dimensions".to_string(), + ); + return Vec::new(); + }; + + if !self.ensure_memory_available(path, width, height) { + return Vec::new(); + } + + // Mark image as decoding and create the decode task + self.large_image_manager.mark_decoding(path.clone()); + vec![self.create_decode_command(path.clone())] + } + + fn ensure_memory_available(&mut self, path: &PathBuf, width: u32, height: u32) -> bool { + let (has_memory, error_opt) = check_memory_available(width, height); + + if has_memory { + return true; + } + + // Try clearing cache + if self.large_image_manager.cache_is_empty() { + if let Some(error_msg) = error_opt { + self.large_image_manager + .store_error(path.clone(), error_msg); + log::warn!( + "Cannot load {}: insufficient memory and cache is empty", + path.display() + ); } + return false; } - commands + log::info!( + "Insufficient memory, clearing {} cached images", + self.large_image_manager.cache_size() + ); + self.large_image_manager.clear_cache(); + + let (has_memory_after_clear, error_opt_after) = check_memory_available(width, height); + + if has_memory_after_clear { + log::info!("Memory available after cache clear, proceeding with decode"); + return true; + } + + if let Some(error_msg) = error_opt_after { + self.large_image_manager + .store_error(path.clone(), error_msg); + log::warn!( + "Cannot load {}: insufficient memory even after cache clear", + path.display() + ); + } + false + } + + fn create_decode_command(&self, path: PathBuf) -> Command { + Command::Iced( + cosmic::iced::Task::perform(decode_large_image(path), |result| { + result + .map(|(path, width, height, pixels)| { + Message::ImageDecoded(path, width, height, pixels) + }) + .unwrap_or_else(|| Message::AutoScroll(None)) + }) + .into(), + ) } pub fn change_location(&mut self, location: &Location, history_i_opt: Option) { From 76c56d5d3b23d32687ab750f90d55fd1148b082f Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Sun, 16 Nov 2025 19:49:31 +0100 Subject: [PATCH 5/7] improve and simplify heuristics --- src/large_image.rs | 337 +++++++++++++++++++++++++++------------------ src/tab.rs | 302 ++++++++++------------------------------ 2 files changed, 277 insertions(+), 362 deletions(-) diff --git a/src/large_image.rs b/src/large_image.rs index 7510348..1b7fbf6 100644 --- a/src/large_image.rs +++ b/src/large_image.rs @@ -8,10 +8,6 @@ use std::{ /// Bytes per pixel in RGBA format (Red, Green, Blue, Alpha = 4 bytes) pub const RGBA_BYTES_PER_PIXEL: u64 = 4; -/// Overhead factor for image decoding operations (30% additional memory for decode buffers, -/// fragment allocations, and intermediate representations during image decoding) -const DECODE_OVERHEAD_FACTOR: f64 = 1.3; - /// System memory reserve in MB to maintain for system stability (prevents thrashing) /// Note: RAM checking is currently only available on Linux via procfs. /// On Windows and macOS, only GPU buffer limits are enforced. @@ -27,11 +23,6 @@ const GALLERY_MEMORY_LIMIT_MB: u64 = 2000; /// Must match the atlas SIZE constant in libcosmic/iced/wgpu/src/image/atlas.rs pub const ATLAS_FRAGMENT_SIZE: u32 = 4096; -/// Conservative GPU buffer size limit in MB. Each atlas fragment can be up to this size. -/// Based on wgpu device limits - most GPUs support at least 256MB buffers. -/// Reference: https://docs.rs/wgpu/latest/wgpu/struct.Limits.html#structfield.max_buffer_size -const MAX_GPU_BUFFER_MB: u64 = 256; - /// Conversion factor: 1 MB = 1024 * 1024 bytes (binary megabyte, used for RAM calculations) pub const MB_TO_BYTES: u64 = 1024 * 1024; @@ -39,8 +30,78 @@ pub const MB_TO_BYTES: u64 = 1024 * 1024; /// The image crate's memory limits use decimal MB, not binary MB. pub const DECIMAL_MB_TO_BYTES: u64 = 1000 * 1000; -/// Maximum dimension for image decoding -pub const MAX_DIMENSION_FOR_DECODE: u32 = 65536; +/// Check if an image's dimensions would exceed the available memory budget. +/// Returns true if the image is too large to decode. +pub fn exceeds_memory_limit(width: u32, height: u32, memory_limit_mb: u64) -> bool { + let Some(bytes_needed) = calculate_image_memory(width, height) else { + // Overflow in calculation means it definitely exceeds any reasonable limit + return true; + }; + + let max_bytes = memory_limit_mb * MB_TO_BYTES; + bytes_needed > max_bytes +} + +/// Check if an image should use GPU tiling for display. +/// Images larger than the atlas fragment size need to be split into tiles for GPU upload. +pub fn should_use_tiling(width: u32, height: u32) -> bool { + width > ATLAS_FRAGMENT_SIZE || height > ATLAS_FRAGMENT_SIZE +} + +/// Determine if an image should use the dedicated worker for thumbnail generation. +/// Returns (use_dedicated_worker, effective_max_mb, effective_jobs). +/// +/// Large images that exceed per-worker memory budget get routed to a dedicated worker +/// with full memory budget. Smaller images use the normal parallel worker pool. +pub fn should_use_dedicated_worker( + width: u32, + height: u32, + total_budget_mb: u64, + parallel_workers: usize, +) -> (bool, u64, usize) { + if width == 0 || height == 0 { + log::warn!( + "Invalid image dimensions {}x{}, using normal queue", + width, + height + ); + return (false, total_budget_mb, parallel_workers); + } + + let Some(bytes_needed) = calculate_image_memory(width, height) else { + log::warn!( + "Image dimensions {}x{} overflow memory calculation, using normal queue", + width, + height + ); + return (false, total_budget_mb, parallel_workers); + }; + + let mb_needed = bytes_needed / MB_TO_BYTES; + let per_worker_budget_mb = total_budget_mb / parallel_workers as u64; + + if mb_needed > per_worker_budget_mb { + log::info!( + "Large image {}x{} needs {}MB (exceeds per-worker {}MB), using dedicated worker", + width, + height, + mb_needed, + per_worker_budget_mb + ); + // Use dedicated worker with full budget + (true, total_budget_mb, 1) + } else { + log::debug!( + "Normal image {}x{} needs {}MB (within per-worker {}MB), using parallel workers", + width, + height, + mb_needed, + per_worker_budget_mb + ); + // Use parallel worker pool with shared budget + (false, total_budget_mb, parallel_workers) + } +} /// Get the dimensions of an image without fully decoding it pub fn get_image_dimensions(path: &Path) -> Option<(u32, u32)> { @@ -67,17 +128,67 @@ pub fn get_image_dimensions(path: &Path) -> Option<(u32, u32)> { } } -/// Check if there's sufficient memory to decode an image. -/// -/// This function performs two types of checks: -/// 1. System RAM availability (Linux only via procfs) -/// 2. GPU buffer limits (all platforms) -/// -/// Platform-specific behavior: -/// - Linux: Full RAM checking via /proc/meminfo + GPU checks -/// - Windows/macOS: GPU buffer checks only (RAM checking not yet implemented) -/// +/// Calculate the memory required to decode an image in bytes. +/// Returns None if the calculation overflows. +fn calculate_image_memory(width: u32, height: u32) -> Option { + let pixels = (width as u64).checked_mul(height as u64)?; + pixels.checked_mul(RGBA_BYTES_PER_PIXEL) +} + +/// Check if there's sufficient system RAM to decode an image (Linux only). /// Returns: (has_memory, error_message) +#[cfg(target_os = "linux")] +fn check_ram_available(width: u32, height: u32) -> (bool, Option) { + use procfs::Current; + + let Some(bytes_needed) = calculate_image_memory(width, height) else { + let error_msg = format!( + "Image dimensions too large: {}x{} causes overflow in memory calculation", + width, height + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + }; + + let mb_needed = bytes_needed / MB_TO_BYTES; + + match procfs::Meminfo::current() { + Ok(meminfo) => { + // MemAvailable includes reclaimable cache and is the best estimate of + // actually available memory for new allocations + let available_kb = meminfo.mem_available.unwrap_or(0); + let available_bytes = available_kb * 1024; + + // Maintain system reserve to prevent thrashing and OOM killer + let min_reserve_bytes = SYSTEM_MEMORY_RESERVE_MB * MB_TO_BYTES; + let usable_bytes = available_bytes.saturating_sub(min_reserve_bytes); + + if bytes_needed > usable_bytes { + let available_mb = available_bytes / MB_TO_BYTES; + let error_msg = format!( + "Insufficient memory: need {}MB, available {}MB. Try closing other applications.", + mb_needed, available_mb + ); + log::warn!("{}", error_msg); + return (false, Some(error_msg)); + } + + (true, None) + } + Err(e) => { + log::warn!("Failed to read /proc/meminfo: {}. Skipping RAM check.", e); + // Graceful fallback: assume RAM is available + (true, None) + } + } +} + +#[cfg(not(target_os = "linux"))] +fn check_ram_available(_width: u32, _height: u32) -> (bool, Option) { + // RAM checking not implemented for this platform + (true, None) +} + pub fn check_memory_available(width: u32, height: u32) -> (bool, Option) { if width == 0 || height == 0 { let error_msg = format!( @@ -88,113 +199,8 @@ pub fn check_memory_available(width: u32, height: u32) -> (bool, Option) return (false, Some(error_msg)); } - let pixels = match (width as u64).checked_mul(height as u64) { - Some(p) => p, - None => { - let error_msg = format!( - "Image dimensions too large: {}x{} causes overflow in pixel calculation", - width, height - ); - log::error!("{}", error_msg); - return (false, Some(error_msg)); - } - }; - - let bytes_needed = match pixels.checked_mul(RGBA_BYTES_PER_PIXEL) { - Some(b) => b, - None => { - let error_msg = format!( - "Image memory requirements overflow: {}x{} pixels requires more than {} bytes", - width, - height, - u64::MAX - ); - log::error!("{}", error_msg); - return (false, Some(error_msg)); - } - }; - - // Add overhead for decode buffers, fragment allocations, and intermediate representations - let bytes_with_overhead = (bytes_needed as f64 * DECODE_OVERHEAD_FACTOR) as u64; - let mb_needed = bytes_with_overhead / MB_TO_BYTES; - - // Check system RAM availability (Linux only) - #[cfg(target_os = "linux")] - { - use procfs::Current; - match procfs::Meminfo::current() { - Ok(meminfo) => { - // MemAvailable includes reclaimable cache and is the best estimate of - // actually available memory for new allocations - let available_kb = meminfo.mem_available.unwrap_or(0); - let available_bytes = available_kb * 1024; - - // Maintain system reserve to prevent thrashing and OOM killer - let min_reserve_bytes = SYSTEM_MEMORY_RESERVE_MB * MB_TO_BYTES; - let usable_bytes = available_bytes.saturating_sub(min_reserve_bytes); - - if bytes_with_overhead > usable_bytes { - let available_mb = available_bytes / MB_TO_BYTES; - let error_msg = format!( - "Insufficient memory: need {}MB, available {}MB. Try closing other applications.", - mb_needed, available_mb - ); - log::warn!("{}", error_msg); - return (false, Some(error_msg)); - } - } - Err(e) => { - log::warn!("Failed to read /proc/meminfo: {}. Skipping RAM check.", e); - // Graceful fallback: continue to GPU checks - } - } - } - - // Note: RAM checking not implemented for Windows/macOS - // These platforms will only validate against GPU buffer limits below - #[cfg(not(target_os = "linux"))] - { - log::debug!( - "RAM checking not available on this platform. Only GPU limits will be enforced." - ); - } - - // Check GPU fragment/atlas tile limits - // Large images are split into atlas fragments for GPU upload. - // Each fragment must fit within GPU buffer size limits. - let fragment_bytes = - (ATLAS_FRAGMENT_SIZE as u64) * (ATLAS_FRAGMENT_SIZE as u64) * RGBA_BYTES_PER_PIXEL; - let max_gpu_buffer_bytes = MAX_GPU_BUFFER_MB * MB_TO_BYTES; - - let fragments_x = (width + ATLAS_FRAGMENT_SIZE - 1) / ATLAS_FRAGMENT_SIZE; - let fragments_y = (height + ATLAS_FRAGMENT_SIZE - 1) / ATLAS_FRAGMENT_SIZE; - let fragment_count = fragments_x as u64 * fragments_y as u64; - - // Fragments are uploaded sequentially, so we only need one fragment buffer at a time. - // However, each individual fragment must fit within GPU buffer size limits. - if fragment_bytes > max_gpu_buffer_bytes { - let max_dimension = (MAX_GPU_BUFFER_MB * MB_TO_BYTES / RGBA_BYTES_PER_PIXEL) as f64; - let max_dimension = (max_dimension.sqrt() as u32).saturating_sub(100); // Add safety margin - - let error_msg = format!( - "Image too large for GPU: {}x{} pixels exceeds GPU buffer limits. \ - Maximum supported dimension is approximately {}x{} pixels.", - width, height, max_dimension, max_dimension - ); - log::error!("{}", error_msg); - return (false, Some(error_msg)); - } - - log::debug!( - "Memory check passed: {}x{} image needs {}MB RAM, will use {} GPU fragment(s) of {}MB each", - width, - height, - mb_needed, - fragment_count, - fragment_bytes / MB_TO_BYTES - ); - - (true, None) + // Check system RAM availability + check_ram_available(width, height) } /// Decode a large image asynchronously in a blocking thread pool. @@ -256,7 +262,6 @@ pub async fn decode_large_image(path: PathBuf) -> Option<(PathBuf, u32, u32, Vec .flatten() } - /// Manages state and operations for large image decoding in gallery mode #[derive(Debug, Default)] pub struct LargeImageManager { @@ -285,17 +290,14 @@ impl LargeImageManager { self.decode_errors.get(path) } - pub fn mark_decoding(&mut self, path: PathBuf) { - self.decoding_images.insert(path); - } - pub fn store_decoded(&mut self, path: PathBuf, handle: widget::image::Handle) { self.decoded_images.insert(path.clone(), handle); self.decoding_images.remove(&path); } pub fn store_error(&mut self, path: PathBuf, error: String) { - self.decode_errors.insert(path, error); + self.decode_errors.insert(path.clone(), error); + self.decoding_images.remove(&path); } pub fn clear_error(&mut self, path: &Path) { @@ -317,4 +319,71 @@ impl LargeImageManager { pub fn cache_is_empty(&self) -> bool { self.decoded_images.is_empty() } + + /// Attempt to decode a large image, checking memory availability first. + /// Returns true if decode was initiated, false if skipped due to insufficient memory. + pub fn try_decode(&mut self, path: &PathBuf) -> bool { + self.clear_error(path); + + // Check if already decoded or decoding + if self.get_decoded(path).is_some() || self.is_decoding(path) { + return false; + } + + let Some((width, height)) = get_image_dimensions(path) else { + self.store_error(path.clone(), "Failed to read image dimensions".to_string()); + return false; + }; + + if !self.ensure_memory_available(path, width, height) { + return false; + } + + // Mark as decoding + self.decoding_images.insert(path.clone()); + true + } + + /// Check if sufficient memory is available, clearing cache if needed. + /// Returns true if memory is available, false otherwise. + fn ensure_memory_available(&mut self, path: &PathBuf, width: u32, height: u32) -> bool { + let (has_memory, error_opt) = check_memory_available(width, height); + + if has_memory { + return true; + } + + if self.cache_is_empty() { + if let Some(error_msg) = error_opt { + self.store_error(path.clone(), error_msg); + log::warn!( + "Cannot load {}: insufficient memory and cache is empty", + path.display() + ); + } + return false; + } + + log::info!( + "Insufficient memory, clearing {} cached images", + self.cache_size() + ); + self.clear_cache(); + + let (has_memory_after_clear, error_opt_after) = check_memory_available(width, height); + + if has_memory_after_clear { + log::info!("Memory available after cache clear, proceeding with decode"); + return true; + } + + if let Some(error_msg) = error_opt_after { + self.store_error(path.clone(), error_msg); + log::warn!( + "Cannot load {}: insufficient memory even after cache clear", + path.display() + ); + } + false + } } diff --git a/src/tab.rs b/src/tab.rs index 944eb13..421d6c7 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -80,9 +80,8 @@ use crate::{ dialog::DialogKind, fl, large_image::{ - DECIMAL_MB_TO_BYTES, LargeImageManager, MAX_DIMENSION_FOR_DECODE, ATLAS_FRAGMENT_SIZE, - MB_TO_BYTES, RGBA_BYTES_PER_PIXEL, check_memory_available, decode_large_image, - get_image_dimensions, + LargeImageManager, decode_large_image, exceeds_memory_limit, should_use_dedicated_worker, + should_use_tiling, }, localize::{LANGUAGE_SORTER, LOCALE}, menu, mime_app, @@ -103,14 +102,11 @@ const MAX_SEARCH_RESULTS: usize = 200; //TODO: configurable thumbnail size? const THUMBNAIL_SIZE: u32 = (ICON_SIZE_GRID as u32) * (ICON_SCALE_MAX as u32); -// Semaphore for normal-sized images (4 parallel workers) -pub static THUMB_SEMAPHORE_NORMAL: LazyLock = +// Thumbnail generation semaphore - limits parallel thumbnail workers +// Uses 4 workers for balanced throughput and memory usage +pub static THUMB_SEMAPHORE: LazyLock = LazyLock::new(|| tokio::sync::Semaphore::const_new(4)); -// Semaphore for large images that would exceed per-worker memory limit (1 worker with full budget) -pub static THUMB_SEMAPHORE_LARGE: LazyLock = - LazyLock::new(|| tokio::sync::Semaphore::const_new(1)); - pub(crate) static SORT_OPTION_FALLBACK: LazyLock> = LazyLock::new(|| { FxHashMap::from_iter(dirs::download_dir().into_iter().map(|dir| { @@ -1722,11 +1718,7 @@ impl ItemMetadata { #[derive(Debug)] pub enum ItemThumbnail { NotImage, - Image( - widget::image::Handle, - Option<(u32, u32)>, - Option, - ), + Image(widget::image::Handle, Option<(u32, u32)>), Svg(widget::svg::Handle), Text(widget::text_editor::Content), } @@ -1735,9 +1727,7 @@ impl Clone for ItemThumbnail { fn clone(&self) -> Self { match self { Self::NotImage => Self::NotImage, - Self::Image(handle, size_opt, full_handle_opt) => { - Self::Image(handle.clone(), *size_opt, full_handle_opt.clone()) - } + Self::Image(handle, size_opt) => Self::Image(handle.clone(), *size_opt), Self::Svg(handle) => Self::Svg(handle.clone()), // Content cannot be cloned simply Self::Text(content) => { @@ -1769,16 +1759,9 @@ impl ItemThumbnail { Err(_) => size.map(|s| (s.pixel_size(), s.pixel_size())), }; - // Create and cache the full-size handle for large images that need GPU tiling - // Images >4096 pixels get fragmented into multiple tiles for GPU upload - let full_handle = original_dims - .filter(|(w, h)| *w > ATLAS_FRAGMENT_SIZE || *h > ATLAS_FRAGMENT_SIZE) - .map(|_| widget::image::Handle::from_path(path)); - return Self::Image( widget::image::Handle::from_path(thumbnail_path), original_dims, - full_handle, ); } CachedThumbnail::Failed => { @@ -1818,22 +1801,21 @@ impl ItemThumbnail { let mut tried_supported_file = false; // First try built-in image thumbnailer if mime.type_() == mime::IMAGE && check_size("image", max_size_mb * 1000 * 1000) { - // Check for extremely large dimensions that would cause memory issues during decoding + // Check if image dimensions would exceed available memory budget // The GPU tiling system can handle large images, but we still need to decode them first - // Set a reasonable limit to prevent OOM during image decoding let dimensions_ok = match image::image_dimensions(path) { Ok((width, height)) => { - if width > MAX_DIMENSION_FOR_DECODE || height > MAX_DIMENSION_FOR_DECODE { + if exceeds_memory_limit(width, height, max_mem) { log::warn!( - "skipping thumbnail generation for {}: dimensions {}x{} exceed decode limit of {}", + "skipping thumbnail generation for {}: {}x{} image would exceed {}MB memory budget", path.display(), width, height, - MAX_DIMENSION_FOR_DECODE + max_mem ); false } else { - if width > ATLAS_FRAGMENT_SIZE || height > ATLAS_FRAGMENT_SIZE { + if should_use_tiling(width, height) { log::info!( "Large image {}x{} detected, will use GPU tiling for display", width, @@ -1912,12 +1894,6 @@ impl ItemThumbnail { if let Some(dyn_img) = dyn_img { let (img_width, img_height) = (dyn_img.width(), dyn_img.height()); - let full_handle = - if img_width > ATLAS_FRAGMENT_SIZE || img_height > ATLAS_FRAGMENT_SIZE { - Some(widget::image::Handle::from_path(path)) - } else { - None - }; if let Ok(cacher) = thumbnail_cacher.as_ref() { match cacher.update_with_image(dyn_img) { @@ -1925,7 +1901,6 @@ impl ItemThumbnail { return Self::Image( widget::image::Handle::from_path(thumb_path), Some((img_width, img_height)), - full_handle, ); } Err(err) => { @@ -1944,7 +1919,6 @@ impl ItemThumbnail { thumbnail.into_raw(), ), Some((img_width, img_height)), - full_handle, ); } } @@ -2072,7 +2046,6 @@ impl ItemThumbnail { image.into_raw(), ), None, - None, ), file, )); @@ -2158,7 +2131,7 @@ impl Item { .unwrap_or(&ItemThumbnail::NotImage) { ItemThumbnail::NotImage => icon, - ItemThumbnail::Image(handle, _original_dims, _full_handle_opt) => { + ItemThumbnail::Image(handle, _original_dims) => { // Preview pane: ALWAYS show thumbnail for instant, responsive UI // Full resolution loading happens in gallery mode widget::image(handle.clone()).into() @@ -2996,10 +2969,16 @@ impl Tab { return Vec::new(); }; - let Some(ItemThumbnail::Image(_, _, _)) = &item.thumbnail_opt else { + let Some(ItemThumbnail::Image(_, original_dims)) = &item.thumbnail_opt else { return Vec::new(); }; + if let Some((w, h)) = original_dims { + if !should_use_tiling(*w, *h) { + return Vec::new(); + } + } + let Some(path) = item.path_opt() else { return Vec::new(); }; @@ -3007,94 +2986,21 @@ impl Tab { // Clone path to avoid borrow checker issues let path = path.to_path_buf(); - // Clear any previous errors for this image - self.large_image_manager.clear_error(&path); - - // Check if image is already decoded or currently decoding - if self.large_image_manager.get_decoded(&path).is_some() - || self.large_image_manager.is_decoding(&path) - { - return Vec::new(); + // Try to decode the image using LargeImageManager + if self.large_image_manager.try_decode(&path) { + vec![Command::Iced( + cosmic::iced::Task::perform(decode_large_image(path), |result| { + result + .map(|(path, width, height, pixels)| { + Message::ImageDecoded(path, width, height, pixels) + }) + .unwrap_or_else(|| Message::AutoScroll(None)) + }) + .into(), + )] + } else { + Vec::new() } - - // Try to decode the image - self.try_decode_image(&path) - } - - /// Attempt to decode a large image, handling memory constraints - fn try_decode_image(&mut self, path: &PathBuf) -> Vec { - let Some((width, height)) = get_image_dimensions(path) else { - self.large_image_manager.store_error( - path.clone(), - "Failed to read image dimensions".to_string(), - ); - return Vec::new(); - }; - - if !self.ensure_memory_available(path, width, height) { - return Vec::new(); - } - - // Mark image as decoding and create the decode task - self.large_image_manager.mark_decoding(path.clone()); - vec![self.create_decode_command(path.clone())] - } - - fn ensure_memory_available(&mut self, path: &PathBuf, width: u32, height: u32) -> bool { - let (has_memory, error_opt) = check_memory_available(width, height); - - if has_memory { - return true; - } - - // Try clearing cache - if self.large_image_manager.cache_is_empty() { - if let Some(error_msg) = error_opt { - self.large_image_manager - .store_error(path.clone(), error_msg); - log::warn!( - "Cannot load {}: insufficient memory and cache is empty", - path.display() - ); - } - return false; - } - - log::info!( - "Insufficient memory, clearing {} cached images", - self.large_image_manager.cache_size() - ); - self.large_image_manager.clear_cache(); - - let (has_memory_after_clear, error_opt_after) = check_memory_available(width, height); - - if has_memory_after_clear { - log::info!("Memory available after cache clear, proceeding with decode"); - return true; - } - - if let Some(error_msg) = error_opt_after { - self.large_image_manager - .store_error(path.clone(), error_msg); - log::warn!( - "Cannot load {}: insufficient memory even after cache clear", - path.display() - ); - } - false - } - - fn create_decode_command(&self, path: PathBuf) -> Command { - Command::Iced( - cosmic::iced::Task::perform(decode_large_image(path), |result| { - result - .map(|(path, width, height, pixels)| { - Message::ImageDecoded(path, width, height, pixels) - }) - .unwrap_or_else(|| Message::AutoScroll(None)) - }) - .into(), - ) } pub fn change_location(&mut self, location: &Location, history_i_opt: Option) { @@ -3556,6 +3462,10 @@ impl Tab { for (_, item) in &indices { if item.selected && item.can_gallery() { self.gallery = !self.gallery; + + if self.gallery { + commands.extend(self.trigger_async_decode()); + } break; } } @@ -4023,7 +3933,7 @@ impl Tab { if item.location_opt.as_ref() == Some(&location) { let handle_opt = match &thumbnail { ItemThumbnail::NotImage => None, - ItemThumbnail::Image(handle, _, _) => Some(widget::icon::Handle { + ItemThumbnail::Image(handle, _) => Some(widget::icon::Handle { symbolic: false, data: widget::icon::Data::Image(handle.clone()), }), @@ -4406,50 +4316,58 @@ impl Tab { .unwrap_or(&ItemThumbnail::NotImage) { ItemThumbnail::NotImage => {} - ItemThumbnail::Image(handle, _original_dims, full_handle_opt) => { + ItemThumbnail::Image(handle, original_dims) => { // Determine which image to show based on async decode state - let (image_handle, is_loading, error_msg_opt) = if let Some(path) = - item.path_opt() - { + let mut is_loading = false; + let mut error_msg_opt = None; + let image_handle = if let Some(path) = item.path_opt() { if let Some(error_msg) = self.large_image_manager.get_error(path) { - (handle, false, Some(error_msg.clone())) + error_msg_opt = Some(error_msg.clone()); + handle.clone() } else if let Some(decoded_handle) = self.large_image_manager.get_decoded(path) { // Full resolution ready --> use it - (decoded_handle, false, None) + decoded_handle.clone() } else if self.large_image_manager.is_decoding(path) { // Currently decoding --> show thumbnail with loading indicator - (handle, true, None) - } else if let Some(full_handle) = full_handle_opt { - // Large image with tiled handle --> use it - (full_handle, false, None) + is_loading = true; + handle.clone() + } else if let Some((w, h)) = original_dims { + // Check if image needs tiling + if should_use_tiling(*w, *h) { + // Large image --> show thumbnail only + handle.clone() + } else { + // Normal-sized image --> load full resolution directly + widget::image::Handle::from_path(path) + } } else { - // Not decoded yet --> show thumbnail - (handle, false, None) + // No dimensions available --> show thumbnail + handle.clone() } } else { - (handle, false, None) + handle.clone() }; let content: cosmic::Element<'_, Message> = if let Some(error_msg) = error_msg_opt { widget::column() - .push(widget::image(image_handle.clone())) + .push(widget::image(image_handle)) .push(widget::text(format!("⚠ {}", error_msg)).size(13)) .padding(space_xs) .align_x(cosmic::iced::Alignment::Center) .into() } else if is_loading { widget::column() - .push(widget::image(image_handle.clone())) + .push(widget::image(image_handle)) .push(widget::text("Loading full resolution...").size(14)) .padding(space_xs) .align_x(cosmic::iced::Alignment::Center) .into() } else { //TODO: use widget::image::viewer, when its zoom can be reset - widget::image(image_handle.clone()).into() + widget::image(image_handle).into() }; element_opt = @@ -5884,85 +5802,18 @@ impl Tab { let max_mb = u64::from(self.thumb_config.max_mem_mb.get()); let max_size = u64::from(self.thumb_config.max_size_mb.get()); - // Determine which queue to use based on image memory requirements. - // This routing ensures large images get dedicated worker with full memory budget, - // while normal images share 4-worker parallel queue for better throughput. - let (use_large_queue, effective_max_mb, effective_jobs) = if mime.type_() - == mime::IMAGE - { - log::debug!("Checking dimensions for image: {}", path.display()); + // Determine effective memory budget based on image size + let (effective_max_mb, effective_jobs) = if mime.type_() == mime::IMAGE { match image::image_dimensions(&path) { Ok((width, height)) => { - if width == 0 || height == 0 { - log::warn!( - "Invalid image dimensions {}x{} for {}, using normal queue", - width, - height, - path.display() - ); - (false, max_mb, max_jobs) - } else if max_jobs == 0 { - log::error!( - "Configuration error: max_jobs is 0, using fallback (1 job)" - ); - (true, max_mb, 1) - } else { - let pixels = (width as u64).saturating_mul(height as u64); - let bytes_needed = pixels.saturating_mul(RGBA_BYTES_PER_PIXEL); - let mb_needed = bytes_needed / MB_TO_BYTES; - - // Calculate per-job limit with normal queue (shared memory budget) - // Use decimal MB conversion to match image crate's limit calculation - let total_bytes = max_mb.saturating_mul(DECIMAL_MB_TO_BYTES); - let per_job_limit = total_bytes / max_jobs as u64; - let per_job_limit_mb = per_job_limit / MB_TO_BYTES; - - log::debug!( - "Image {}x{} needs {}MB, per-job limit is {}MB (normal queue)", - width, - height, - mb_needed, - per_job_limit_mb - ); - - if bytes_needed > per_job_limit { - // Image exceeds per-job limit --> route to dedicated large image queue - log::info!( - "Image {}x{} needs {}MB (exceeds per-job limit of {}MB), \ - using large image queue (1 worker, {}MB full budget)", - width, - height, - mb_needed, - per_job_limit_mb, - max_mb - ); - (true, max_mb, 1) - } else { - // Image fits in per-job limit --> use normal parallel queue - log::debug!( - "Image {}x{} fits in normal queue ({}MB < {}MB limit)", - width, - height, - mb_needed, - per_job_limit_mb - ); - (false, max_mb, max_jobs) - } - } - } - Err(e) => { - // Cannot determine size, use normal queue - log::debug!( - "Failed to get dimensions for {}: {}, using normal queue", - path.display(), - e - ); - (false, max_mb, max_jobs) + let (_use_dedicated, eff_mb, eff_jobs) = + should_use_dedicated_worker(width, height, max_mb, max_jobs); + (eff_mb, eff_jobs) } + Err(_) => (max_mb, max_jobs), } } else { - // Non-image, use normal queue - (false, max_mb, max_jobs) + (max_mb, max_jobs) }; subscriptions.push(Subscription::run_with_id( @@ -5971,12 +5822,8 @@ impl Tab { let message = { let path = path.clone(); - // Acquire from appropriate semaphore based on image size - if use_large_queue { - _ = THUMB_SEMAPHORE_LARGE.acquire().await; - } else { - _ = THUMB_SEMAPHORE_NORMAL.acquire().await; - } + // Acquire semaphore permit + _ = THUMB_SEMAPHORE.acquire().await; tokio::task::spawn_blocking(move || { let start = Instant::now(); @@ -5990,10 +5837,9 @@ impl Tab { max_size, ); log::debug!( - "thumbnailed {} in {:?} (queue: {})", + "thumbnailed {} in {:?}", path.display(), - start.elapsed(), - if use_large_queue { "large" } else { "normal" } + start.elapsed() ); Message::Thumbnail(path, thumbnail) }) From bf7b9c192c7ae7efb3853f457f5cd6595926a4b2 Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Sun, 16 Nov 2025 21:31:27 +0100 Subject: [PATCH 6/7] implement adaptive sampling for optimal image quality of large images in gallery view while keeping the memory foodprint minimal and UI blocks from GPU buffer uploads minimal and as short as possible --- src/large_image.rs | 254 +++++++++++++++++++++++++++++++++++++++++---- src/tab.rs | 66 ++++++++---- 2 files changed, 279 insertions(+), 41 deletions(-) diff --git a/src/large_image.rs b/src/large_image.rs index 1b7fbf6..6a2cf6b 100644 --- a/src/large_image.rs +++ b/src/large_image.rs @@ -30,6 +30,59 @@ pub const MB_TO_BYTES: u64 = 1024 * 1024; /// The image crate's memory limits use decimal MB, not binary MB. pub const DECIMAL_MB_TO_BYTES: u64 = 1000 * 1000; +/// Scale factor for HiDPI displays - decode at higher resolution than display size +/// for better quality on high-DPI screens. 1.5x provides good balance between +/// quality and memory usage and also prevets re-decoding on small windows size adjustments. +const DISPLAY_SCALE_FACTOR: f32 = 1.5; + +/// Calculate optimal target dimensions for decoding based on display size. +/// Returns None if no resizing is needed (image is smaller than display). +/// +/// This helps reduce memory usage by decoding large images at a resolution +/// appropriate for the display, rather than always using full resolution. +pub fn calculate_target_dimensions( + image_width: u32, + image_height: u32, + display_width: u32, + display_height: u32, +) -> Option<(u32, u32)> { + let target_width = (display_width as f32 * DISPLAY_SCALE_FACTOR) as u32; + let target_height = (display_height as f32 * DISPLAY_SCALE_FACTOR) as u32; + + if image_width <= target_width && image_height <= target_height { + return None; + } + + let image_aspect = image_width as f32 / image_height as f32; + let target_aspect = target_width as f32 / target_height as f32; + + let (new_width, new_height) = if image_aspect > target_aspect { + let w = target_width; + let h = (target_width as f32 / image_aspect) as u32; + (w, h) + } else { + let h = target_height; + let w = (target_height as f32 * image_aspect) as u32; + (w, h) + }; + + let new_width = new_width.max(1); + let new_height = new_height.max(1); + + log::info!( + "Calculated target dimensions: {}x{} -> {}x{} (display: {}x{}, scale: {}x)", + image_width, + image_height, + new_width, + new_height, + display_width, + display_height, + DISPLAY_SCALE_FACTOR + ); + + Some((new_width, new_height)) +} + /// Check if an image's dimensions would exceed the available memory budget. /// Returns true if the image is too large to decode. pub fn exceeds_memory_limit(width: u32, height: u32, memory_limit_mb: u64) -> bool { @@ -208,7 +261,10 @@ pub fn check_memory_available(width: u32, height: u32) -> (bool, Option) /// This function is used for gallery mode where full-resolution images need to be loaded. /// It uses the full memory budget (GALLERY_MEMORY_LIMIT_MB) since only one image /// decodes at a time in gallery mode. -pub async fn decode_large_image(path: PathBuf) -> Option<(PathBuf, u32, u32, Vec)> { +pub async fn decode_large_image( + path: PathBuf, + target_dimensions: Option<(u32, u32)>, +) -> Option<(PathBuf, u32, u32, Vec)> { // Decode image in blocking thread pool (CPU-intensive work should not block) tokio::task::spawn_blocking(move || { log::info!("Starting async decode of {}", path.display()); @@ -227,16 +283,46 @@ pub async fn decode_large_image(path: PathBuf) -> Option<(PathBuf, u32, u32, Vec match reader.decode() { Ok(img) => { let rgba = img.into_rgba8(); - let width = rgba.width(); - let height = rgba.height(); - let pixels = rgba.into_raw(); + let orig_width = rgba.width(); + let orig_height = rgba.height(); - log::info!( - "Decoded {}x{} image: {}", - width, - height, - path.display() - ); + // Resize if target dimensions provided + let (final_img, width, height) = if let Some((target_w, target_h)) = target_dimensions { + log::info!( + "Resizing {}x{} -> {}x{} for memory optimization: {}", + orig_width, orig_height, target_w, target_h, + path.display() + ); + + // Use Lanczos3 for high-quality downsampling + let resized = image::imageops::resize( + &rgba, + target_w, + target_h, + image::imageops::FilterType::Lanczos3, + ); + + let resized_w = resized.width(); + let resized_h = resized.height(); + + log::info!( + "Resize complete: {}x{} image now uses ~{} MB instead of ~{} MB", + resized_w, resized_h, + (resized_w as u64 * resized_h as u64 * 4) / MB_TO_BYTES, + (orig_width as u64 * orig_height as u64 * 4) / MB_TO_BYTES + ); + + (resized, resized_w, resized_h) + } else { + log::info!( + "Decoded {}x{} image at full resolution: {}", + orig_width, orig_height, + path.display() + ); + (rgba, orig_width, orig_height) + }; + + let pixels = final_img.into_raw(); Some((path, width, height, pixels)) } Err(e) => { @@ -269,8 +355,14 @@ pub struct LargeImageManager { decoding_images: HashSet, /// Cache of decoded image handles decoded_images: HashMap, + /// Display dimensions used for each decoded image (for resize detection) + decoded_display_sizes: HashMap, /// Errors encountered during decoding decode_errors: HashMap, + /// Generation counter for each decode to support cancellation. + /// When a new decode is started for the same path, the generation is incremented. + /// Only decodes matching the current generation are accepted when they complete. + decode_generations: HashMap, } impl LargeImageManager { @@ -290,9 +382,40 @@ impl LargeImageManager { self.decode_errors.get(path) } - pub fn store_decoded(&mut self, path: PathBuf, handle: widget::image::Handle) { + /// Store a decoded image if the generation matches (not superseded by newer decode). + /// Returns true if stored, false if rejected due to generation mismatch. + pub fn store_decoded_with_generation( + &mut self, + path: PathBuf, + handle: widget::image::Handle, + display_size: Option<(u32, u32)>, + generation: u64, + ) -> bool { + // Check if this decode is still current (not superseded by a newer one) + if let Some(¤t_gen) = self.decode_generations.get(&path) { + if generation != current_gen { + log::info!( + "Discarding outdated decode for {} (generation {} != current {})", + path.display(), + generation, + current_gen + ); + return false; + } + } + + log::info!( + "Storing decoded image for {} (generation {})", + path.display(), + generation + ); + self.decoded_images.insert(path.clone(), handle); + if let Some(size) = display_size { + self.decoded_display_sizes.insert(path.clone(), size); + } self.decoding_images.remove(&path); + true } pub fn store_error(&mut self, path: PathBuf, error: String) { @@ -320,28 +443,115 @@ impl LargeImageManager { self.decoded_images.is_empty() } - /// Attempt to decode a large image, checking memory availability first. - /// Returns true if decode was initiated, false if skipped due to insufficient memory. - pub fn try_decode(&mut self, path: &PathBuf) -> bool { - self.clear_error(path); - - // Check if already decoded or decoding - if self.get_decoded(path).is_some() || self.is_decoding(path) { + /// Check if an image should be re-decoded due to display size increase. + /// Returns true only if the display size has INCREASED by more than 20% in either dimension. + /// Does NOT re-decode for smaller sizes (GPU can efficiently downscale). + pub fn needs_redecode_for_size( + &self, + path: &Path, + new_display_size: Option<(u32, u32)>, + ) -> bool { + let Some(new_size) = new_display_size else { return false; + }; + + let Some(&old_size) = self.decoded_display_sizes.get(path) else { + return false; + }; + + const REDECODE_THRESHOLD: f32 = 0.2; + + let width_increase = (new_size.0 as f32 / old_size.0 as f32) - 1.0; + let height_increase = (new_size.1 as f32 / old_size.1 as f32) - 1.0; + + let needs_redecode = + width_increase > REDECODE_THRESHOLD || height_increase > REDECODE_THRESHOLD; + + if needs_redecode { + log::info!( + "Display size increased significantly for {}: {}x{} -> {}x{} (increase: {:.1}% width, {:.1}% height) - re-decoding at higher resolution", + path.display(), + old_size.0, + old_size.1, + new_size.0, + new_size.1, + width_increase * 100.0, + height_increase * 100.0 + ); + } else if width_increase < -REDECODE_THRESHOLD || height_increase < -REDECODE_THRESHOLD { + log::debug!( + "Display size decreased for {}: {}x{} -> {}x{} (decrease: {:.1}% width, {:.1}% height) - keeping existing higher resolution", + path.display(), + old_size.0, + old_size.1, + new_size.0, + new_size.1, + width_increase * 100.0, + height_increase * 100.0 + ); + } + + needs_redecode + } + + /// Attempt to decode a large image, checking memory availability first. + /// Returns (should_decode, target_dimensions, generation) tuple. + pub fn try_decode( + &mut self, + path: &PathBuf, + display_dimensions: Option<(u32, u32)>, + ) -> (bool, Option<(u32, u32)>, u64) { + self.clear_error(path); + let is_currently_decoding = self.is_decoding(path); + let needs_redecode = self.needs_redecode_for_size(path, display_dimensions); + + if is_currently_decoding && !needs_redecode { + // Get current generation for the ongoing decode + let generation = self.decode_generations.get(path).copied().unwrap_or(0); + return (false, None, generation); + } + + if self.get_decoded(path).is_some() && !needs_redecode && !is_currently_decoding { + let generation = self.decode_generations.get(path).copied().unwrap_or(0); + return (false, None, generation); } let Some((width, height)) = get_image_dimensions(path) else { self.store_error(path.clone(), "Failed to read image dimensions".to_string()); - return false; + return (false, None, 0); }; - if !self.ensure_memory_available(path, width, height) { - return false; + let target_dimensions = if let Some((display_w, display_h)) = display_dimensions { + calculate_target_dimensions(width, height, display_w, display_h) + } else { + None + }; + + // Check memory for target size (if resizing) or full size + let (check_w, check_h) = target_dimensions.unwrap_or((width, height)); + if !self.ensure_memory_available(path, check_w, check_h) { + return (false, None, 0); + } + + // Increment generation counter (cancels any previous decode) + let generation = self + .decode_generations + .entry(path.clone()) + .and_modify(|g| *g += 1) + .or_insert(1); + let generation = *generation; + + if is_currently_decoding { + log::info!( + "Cancelling previous decode for {} and starting new one (generation {})", + path.display(), + generation + ); } // Mark as decoding self.decoding_images.insert(path.clone()); - true + (true, target_dimensions, generation) } /// Check if sufficient memory is available, clearing cache if needed. diff --git a/src/tab.rs b/src/tab.rs index 421d6c7..c26bb0d 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -1624,7 +1624,7 @@ pub enum Message { HighlightDeactivate(usize), HighlightActivate(usize), DirectorySize(PathBuf, DirSize), - ImageDecoded(PathBuf, u32, u32, Vec), + ImageDecoded(PathBuf, u32, u32, Vec, Option<(u32, u32)>, u64), // path, width, height, pixels, display_size, generation } #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -2986,16 +2986,35 @@ impl Tab { // Clone path to avoid borrow checker issues let path = path.to_path_buf(); - // Try to decode the image using LargeImageManager - if self.large_image_manager.try_decode(&path) { + // Get display size for adaptive resolution + let display_dimensions = self + .size_opt + .get() + .map(|size| (size.width as u32, size.height as u32)); + + // Try to decode the image using LargeImageManager with adaptive resolution + let (should_decode, target_dimensions, generation) = self + .large_image_manager + .try_decode(&path, display_dimensions); + if should_decode { vec![Command::Iced( - cosmic::iced::Task::perform(decode_large_image(path), |result| { - result - .map(|(path, width, height, pixels)| { - Message::ImageDecoded(path, width, height, pixels) - }) - .unwrap_or_else(|| Message::AutoScroll(None)) - }) + cosmic::iced::Task::perform( + decode_large_image(path, target_dimensions), + move |result| { + result + .map(|(path, width, height, pixels)| { + Message::ImageDecoded( + path, + width, + height, + pixels, + display_dimensions, + generation, + ) + }) + .unwrap_or_else(|| Message::AutoScroll(None)) + }, + ) .into(), )] } else { @@ -3955,12 +3974,17 @@ impl Tab { } } } - Message::ImageDecoded(path, width, height, pixels) => { + Message::ImageDecoded(path, width, height, pixels, display_size, generation) => { // Create handle from pre-decoded RGBA data (fast!) let handle = widget::image::Handle::from_rgba(width, height, pixels); - // Store decoded image handle and remove from decoding set - self.large_image_manager.store_decoded(path, handle); + // Store decoded image handle if generation still matches (not superseded) + self.large_image_manager.store_decoded_with_generation( + path, + handle, + display_size, + generation, + ); } Message::ToggleSort(heading_option) => { if !matches!(self.location, Location::Search(..)) { @@ -4324,15 +4348,19 @@ impl Tab { if let Some(error_msg) = self.large_image_manager.get_error(path) { error_msg_opt = Some(error_msg.clone()); handle.clone() + } else if self.large_image_manager.is_decoding(path) { + // Currently decoding (initial or re-decode) --> show cached/thumbnail with loading indicator + is_loading = true; + // Use decoded handle if available (re-decode), otherwise thumbnail (initial decode) + self.large_image_manager + .get_decoded(path) + .cloned() + .unwrap_or_else(|| handle.clone()) } else if let Some(decoded_handle) = self.large_image_manager.get_decoded(path) { - // Full resolution ready --> use it + // Decoded and not currently decoding --> use it decoded_handle.clone() - } else if self.large_image_manager.is_decoding(path) { - // Currently decoding --> show thumbnail with loading indicator - is_loading = true; - handle.clone() } else if let Some((w, h)) = original_dims { // Check if image needs tiling if should_use_tiling(*w, *h) { @@ -4361,7 +4389,7 @@ impl Tab { } else if is_loading { widget::column() .push(widget::image(image_handle)) - .push(widget::text("Loading full resolution...").size(14)) + .push(widget::text("Loading higher resolution...").size(14)) .padding(space_xs) .align_x(cosmic::iced::Alignment::Center) .into() From 4df14044aa33c69c4e07a206620cfc649f90fbff Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Mon, 17 Nov 2025 17:07:35 +0100 Subject: [PATCH 7/7] make sure to not create more thumbnail workers than available CPU cores --- src/tab.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tab.rs b/src/tab.rs index c26bb0d..bda3dca 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -105,7 +105,7 @@ const THUMBNAIL_SIZE: u32 = (ICON_SIZE_GRID as u32) * (ICON_SCALE_MAX as u32); // Thumbnail generation semaphore - limits parallel thumbnail workers // Uses 4 workers for balanced throughput and memory usage pub static THUMB_SEMAPHORE: LazyLock = - LazyLock::new(|| tokio::sync::Semaphore::const_new(4)); + LazyLock::new(|| tokio::sync::Semaphore::const_new(num_cpus::get().min(4))); pub(crate) static SORT_OPTION_FALLBACK: LazyLock> = LazyLock::new(|| {