From 76c56d5d3b23d32687ab750f90d55fd1148b082f Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Sun, 16 Nov 2025 19:49:31 +0100 Subject: [PATCH] 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) })