move large image handling out of tab and into new module large_image
This commit is contained in:
parent
9b6ac00145
commit
0353009321
3 changed files with 355 additions and 281 deletions
320
src/large_image.rs
Normal file
320
src/large_image.rs
Normal file
|
|
@ -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<String>) {
|
||||||
|
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<u8>)> {
|
||||||
|
// 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<PathBuf>,
|
||||||
|
/// Cache of decoded image handles
|
||||||
|
decoded_images: HashMap<PathBuf, widget::image::Handle>,
|
||||||
|
/// Errors encountered during decoding
|
||||||
|
decode_errors: HashMap<PathBuf, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ use config::Config;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
mod key_bind;
|
mod key_bind;
|
||||||
|
pub(crate) mod large_image;
|
||||||
mod localize;
|
mod localize;
|
||||||
mod menu;
|
mod menu;
|
||||||
mod mime_app;
|
mod mime_app;
|
||||||
|
|
|
||||||
315
src/tab.rs
315
src/tab.rs
|
|
@ -49,8 +49,6 @@ use icu::{
|
||||||
use image::{DynamicImage, ImageDecoder, ImageReader};
|
use image::{DynamicImage, ImageDecoder, ImageReader};
|
||||||
use jxl_oxide::integration::JxlDecoder;
|
use jxl_oxide::integration::JxlDecoder;
|
||||||
use mime_guess::{Mime, mime};
|
use mime_guess::{Mime, mime};
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
use procfs::Current;
|
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
|
|
@ -81,6 +79,11 @@ use crate::{
|
||||||
config::{DesktopConfig, ICON_SCALE_MAX, ICON_SIZE_GRID, IconSizes, TabConfig, ThumbCfg},
|
config::{DesktopConfig, ICON_SCALE_MAX, ICON_SIZE_GRID, IconSizes, TabConfig, ThumbCfg},
|
||||||
dialog::DialogKind,
|
dialog::DialogKind,
|
||||||
fl,
|
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},
|
localize::{LANGUAGE_SORTER, LOCALE},
|
||||||
menu, mime_app,
|
menu, mime_app,
|
||||||
mime_icon::{mime_for_path, mime_icon},
|
mime_icon::{mime_for_path, mime_icon},
|
||||||
|
|
@ -108,40 +111,6 @@ pub static THUMB_SEMAPHORE_NORMAL: LazyLock<tokio::sync::Semaphore> =
|
||||||
pub static THUMB_SEMAPHORE_LARGE: LazyLock<tokio::sync::Semaphore> =
|
pub static THUMB_SEMAPHORE_LARGE: LazyLock<tokio::sync::Semaphore> =
|
||||||
LazyLock::new(|| tokio::sync::Semaphore::const_new(1));
|
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<FxHashMap<String, (HeadingOptions, bool)>> =
|
pub(crate) static SORT_OPTION_FALLBACK: LazyLock<FxHashMap<String, (HeadingOptions, bool)>> =
|
||||||
LazyLock::new(|| {
|
LazyLock::new(|| {
|
||||||
FxHashMap::from_iter(dirs::download_dir().into_iter().map(|dir| {
|
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
|
// 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
|
// Images >4096 pixels get fragmented into multiple tiles for GPU upload
|
||||||
let full_handle = original_dims
|
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));
|
.map(|_| widget::image::Handle::from_path(path));
|
||||||
|
|
||||||
return Self::Image(
|
return Self::Image(
|
||||||
|
|
@ -1852,7 +1821,6 @@ impl ItemThumbnail {
|
||||||
// Check for extremely large dimensions that would cause memory issues during decoding
|
// 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
|
// 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
|
// 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) {
|
let dimensions_ok = match image::image_dimensions(path) {
|
||||||
Ok((width, height)) => {
|
Ok((width, height)) => {
|
||||||
if width > MAX_DIMENSION_FOR_DECODE || height > MAX_DIMENSION_FOR_DECODE {
|
if width > MAX_DIMENSION_FOR_DECODE || height > MAX_DIMENSION_FOR_DECODE {
|
||||||
|
|
@ -1865,7 +1833,7 @@ impl ItemThumbnail {
|
||||||
);
|
);
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
if width > 8192 || height > 8192 {
|
if width > ATLAS_FRAGMENT_SIZE || height > ATLAS_FRAGMENT_SIZE {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Large image {}x{} detected, will use GPU tiling for display",
|
"Large image {}x{} detected, will use GPU tiling for display",
|
||||||
width,
|
width,
|
||||||
|
|
@ -1944,11 +1912,12 @@ impl ItemThumbnail {
|
||||||
|
|
||||||
if let Some(dyn_img) = dyn_img {
|
if let Some(dyn_img) = dyn_img {
|
||||||
let (img_width, img_height) = (dyn_img.width(), dyn_img.height());
|
let (img_width, img_height) = (dyn_img.width(), dyn_img.height());
|
||||||
let full_handle = if img_width > 4096 || img_height > 4096 {
|
let full_handle =
|
||||||
Some(widget::image::Handle::from_path(path))
|
if img_width > ATLAS_FRAGMENT_SIZE || img_height > ATLAS_FRAGMENT_SIZE {
|
||||||
} else {
|
Some(widget::image::Handle::from_path(path))
|
||||||
None
|
} else {
|
||||||
};
|
None
|
||||||
|
};
|
||||||
|
|
||||||
if let Ok(cacher) = thumbnail_cacher.as_ref() {
|
if let Ok(cacher) = thumbnail_cacher.as_ref() {
|
||||||
match cacher.update_with_image(dyn_img) {
|
match cacher.update_with_image(dyn_img) {
|
||||||
|
|
@ -2593,221 +2562,7 @@ pub struct Tab {
|
||||||
time_formatter: DateTimeFormatter<fieldsets::T>,
|
time_formatter: DateTimeFormatter<fieldsets::T>,
|
||||||
watch_drag: bool,
|
watch_drag: bool,
|
||||||
window_id: Option<window::Id>,
|
window_id: Option<window::Id>,
|
||||||
decoding_images: std::collections::HashSet<PathBuf>,
|
large_image_manager: LargeImageManager,
|
||||||
decoded_images: std::collections::HashMap<PathBuf, widget::image::Handle>,
|
|
||||||
decode_errors: std::collections::HashMap<PathBuf, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<String>) {
|
|
||||||
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<u8>)> {
|
|
||||||
// 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<u64, OperationError> {
|
async fn calculate_dir_size(path: &Path, controller: Controller) -> Result<u64, OperationError> {
|
||||||
|
|
@ -2929,9 +2684,7 @@ impl Tab {
|
||||||
time_formatter: time_formatter(config.military_time),
|
time_formatter: time_formatter(config.military_time),
|
||||||
watch_drag: true,
|
watch_drag: true,
|
||||||
window_id,
|
window_id,
|
||||||
decoding_images: std::collections::HashSet::new(),
|
large_image_manager: LargeImageManager::new(),
|
||||||
decoded_images: std::collections::HashMap::new(),
|
|
||||||
decode_errors: std::collections::HashMap::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3239,11 +2992,11 @@ impl Tab {
|
||||||
if let Some(ItemThumbnail::Image(_, _, _full_handle_opt)) = &item.thumbnail_opt
|
if let Some(ItemThumbnail::Image(_, _, _full_handle_opt)) = &item.thumbnail_opt
|
||||||
{
|
{
|
||||||
if let Some(path) = item.path_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
|
// Only decode if not already decoded or decoding
|
||||||
if !self.decoded_images.contains_key(path)
|
if self.large_image_manager.get_decoded(path).is_none()
|
||||||
&& !self.decoding_images.contains(path)
|
&& !self.large_image_manager.is_decoding(path)
|
||||||
{
|
{
|
||||||
if let Some((width, height)) = get_image_dimensions(path) {
|
if let Some((width, height)) = get_image_dimensions(path) {
|
||||||
let (has_memory, error_opt) =
|
let (has_memory, error_opt) =
|
||||||
|
|
@ -3251,20 +3004,20 @@ impl Tab {
|
||||||
|
|
||||||
if !has_memory {
|
if !has_memory {
|
||||||
// Insufficient memory --> try clearing cache
|
// Insufficient memory --> try clearing cache
|
||||||
if !self.decoded_images.is_empty() {
|
if !self.large_image_manager.cache_is_empty() {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Insufficient memory, clearing {} cached images",
|
"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
|
// Check again after clearing cache
|
||||||
let (has_memory_after_clear, error_opt_after) =
|
let (has_memory_after_clear, error_opt_after) =
|
||||||
check_memory_available(width, height);
|
check_memory_available(width, height);
|
||||||
if !has_memory_after_clear {
|
if !has_memory_after_clear {
|
||||||
if let Some(error_msg) = error_opt_after {
|
if let Some(error_msg) = error_opt_after {
|
||||||
self.decode_errors
|
self.large_image_manager
|
||||||
.insert(path.clone(), error_msg);
|
.store_error(path.clone(), error_msg);
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Cannot load {}: insufficient memory even after cache clear",
|
"Cannot load {}: insufficient memory even after cache clear",
|
||||||
path.display()
|
path.display()
|
||||||
|
|
@ -3277,7 +3030,8 @@ impl Tab {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if let Some(error_msg) = error_opt {
|
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!(
|
log::warn!(
|
||||||
"Cannot load {}: insufficient memory and cache is empty",
|
"Cannot load {}: insufficient memory and cache is empty",
|
||||||
path.display()
|
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();
|
let path_clone = path.clone();
|
||||||
commands.push(Command::Iced(
|
commands.push(Command::Iced(
|
||||||
|
|
@ -3307,7 +3061,7 @@ impl Tab {
|
||||||
.into(),
|
.into(),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
self.decode_errors.insert(
|
self.large_image_manager.store_error(
|
||||||
path.clone(),
|
path.clone(),
|
||||||
"Failed to read image dimensions".to_string(),
|
"Failed to read image dimensions".to_string(),
|
||||||
);
|
);
|
||||||
|
|
@ -4274,11 +4028,8 @@ impl Tab {
|
||||||
// Create handle from pre-decoded RGBA data (fast!)
|
// Create handle from pre-decoded RGBA data (fast!)
|
||||||
let handle = widget::image::Handle::from_rgba(width, height, pixels);
|
let handle = widget::image::Handle::from_rgba(width, height, pixels);
|
||||||
|
|
||||||
// Store decoded image handle
|
// Store decoded image handle and remove from decoding set
|
||||||
self.decoded_images.insert(path.clone(), handle);
|
self.large_image_manager.store_decoded(path, handle);
|
||||||
|
|
||||||
// Remove from decoding set
|
|
||||||
self.decoding_images.remove(&path);
|
|
||||||
}
|
}
|
||||||
Message::ToggleSort(heading_option) => {
|
Message::ToggleSort(heading_option) => {
|
||||||
if !matches!(self.location, Location::Search(..)) {
|
if !matches!(self.location, Location::Search(..)) {
|
||||||
|
|
@ -4639,12 +4390,14 @@ impl Tab {
|
||||||
let (image_handle, is_loading, error_msg_opt) = if let Some(path) =
|
let (image_handle, is_loading, error_msg_opt) = if let Some(path) =
|
||||||
item.path_opt()
|
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()))
|
(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
|
// Full resolution ready --> use it
|
||||||
(decoded_handle, false, None)
|
(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
|
// Currently decoding --> show thumbnail with loading indicator
|
||||||
(handle, true, None)
|
(handle, true, None)
|
||||||
} else if let Some(full_handle) = full_handle_opt {
|
} else if let Some(full_handle) = full_handle_opt {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue