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; /// 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; /// 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; /// 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 { 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)> { 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 } } } /// 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!( "Invalid image dimensions: {}x{} (zero dimension)", width, height ); log::error!("{}", error_msg); return (false, Some(error_msg)); } // Check system RAM availability check_ram_available(width, height) } /// 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, 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()); // 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 orig_width = rgba.width(); let orig_height = rgba.height(); // 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) => { 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, /// 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 { 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) } /// 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) && 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) { self.decode_errors.insert(path.clone(), error); self.decoding_images.remove(&path); } 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() } /// 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, None, 0); }; 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, target_dimensions, generation) } /// 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: &Path, 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.to_path_buf(), 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.to_path_buf(), error_msg); log::warn!( "Cannot load {}: insufficient memory even after cache clear", path.display() ); } false } }