diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 83ebfddf0d..e48095ef1f 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::sync::mpsc::Sender; use std::sync::{Arc, Condvar, Mutex}; +use std::time::Instant; use image::{DynamicImage, GrayImage}; use serde::{Deserialize, Serialize}; @@ -60,6 +61,7 @@ pub struct GpuImageCache { pub width: u32, pub height: u32, pub transform_hash: u64, + pub staging_buffer: Vec, } pub struct GpuProcessorState { @@ -165,8 +167,13 @@ pub struct AppState { pub thumbnail_geometry_cache: Mutex>, pub lens_db: Mutex>>, pub load_image_generation: Arc, + pub uncropped_preview_seq: AtomicUsize, + pub uncropped_preview_work_mutex: Mutex<()>, + pub uncropped_last_emit: Mutex, pub full_warped_cache: Mutex)>>, pub full_transformed_cache: Mutex>, + pub preview_cache: Mutex>>, + pub processing_active: AtomicBool, pub decoded_image_cache: Mutex, pub thumbnail_manager: Arc, pub metadata_manager: Arc, diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 56fc585659..8245aa42f0 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -1272,7 +1272,25 @@ pub fn generate_thumbnail_data( let mut raw_scale_factor = 1.0f32; let composite_image = if let Some(img) = preloaded_image { - image_loader::composite_patches_on_image(img, &adjustments)? + let has_no_patches = !adjustments + .get("aiPatches") + .and_then(|v| v.as_array()) + .is_some_and(|a| !a.is_empty()); + if has_no_patches { + let cached_base = state.preview_cache.lock().unwrap().as_ref().map(Arc::clone); + if let Some(cached_img) = cached_base { + let (cw, ch) = cached_img.dimensions(); + if cw >= target_res || ch >= target_res { + cached_img.as_ref().clone() + } else { + image_loader::composite_patches_on_image(img, &adjustments)? + } + } else { + image_loader::composite_patches_on_image(img, &adjustments)? + } + } else { + image_loader::composite_patches_on_image(img, &adjustments)? + } } else { let mmap_guard; let vec_guard; diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index 6ecfc78c9c..1e9b16cb8c 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -142,7 +142,7 @@ pub fn get_or_init_gpu_context( #[cfg(not(any(target_os = "android", target_os = "linux")))] let app_handle = _app_handle; - let mut context_lock = state.gpu_context.lock().unwrap(); + let mut context_lock = state.gpu_context.lock().unwrap_or_else(|e| e.into_inner()); if let Some(context) = &*context_lock { return Ok(context.clone()); } @@ -155,7 +155,7 @@ pub fn get_or_init_gpu_context( instance_desc.backends = wgpu::Backends::PRIMARY; } - let flag_path = state.gpu_crash_flag_path.lock().unwrap().clone(); + let flag_path = state.gpu_crash_flag_path.lock().unwrap_or_else(|e| e.into_inner()).clone(); if let Some(p) = &flag_path { if let Some(parent) = p.parent() { let _ = std::fs::create_dir_all(parent); @@ -1651,7 +1651,7 @@ fn process_and_get_dynamic_image_inner( let mut reallocated = false; - let mut processor_lock = state.gpu_processor.lock().unwrap(); + let mut processor_lock = state.gpu_processor.lock().unwrap_or_else(|e| e.into_inner()); let mut needs_new_processor = false; let new_width = (width + 255) & !255; let new_height = (height + 255) & !255; @@ -1731,28 +1731,53 @@ fn process_and_get_dynamic_image_inner( display.current_bind_group = Some(bind_group); } - let mut cache_lock = state.gpu_image_cache.lock().unwrap(); - let mut needs_new_cache = false; - - if let Some(cache) = &*cache_lock { - if cache.transform_hash != transform_hash || cache.width != width || cache.height != height - { - needs_new_cache = true; - } - } else { - needs_new_cache = true; + let mut cache_lock = state.gpu_image_cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(cache) = &*cache_lock + && (cache.width != width || cache.height != height) + { + *cache_lock = None; } - if needs_new_cache { - let old_cache = cache_lock.take(); - drop(old_cache); - - let _ = context.device.poll(wgpu::PollType::Wait { - submission_index: None, - timeout: Some(std::time::Duration::from_millis(500)), - }); + let img_rgba_f16 = to_rgba_f16(base_image); - let img_rgba_f16 = to_rgba_f16(base_image); + if let Some(cache) = cache_lock.as_mut() { + let src_bytes = bytemuck::cast_slice(&img_rgba_f16); + let bytes_per_row = ((width as u32 * 8) + 255) & !255; + let (data, data_size) = if bytes_per_row == width as u32 * 8 { + (src_bytes, src_bytes.len()) + } else { + let row_bytes = width as u32 as usize * 8; + let needed = bytes_per_row as usize * height as usize; + cache.staging_buffer.resize(needed, 0); + for y in 0..height as usize { + let src_start = y * row_bytes; + let dst_start = y * bytes_per_row as usize; + cache.staging_buffer[dst_start..dst_start + row_bytes] + .copy_from_slice(&src_bytes[src_start..src_start + row_bytes]); + } + let data_ref: &[u8] = &cache.staging_buffer; + (data_ref, needed) + }; + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &cache.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &data[..data_size], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: None, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + } else { let texture_size = wgpu::Extent3d { width, height, @@ -1781,6 +1806,7 @@ fn process_and_get_dynamic_image_inner( width, height, transform_hash, + staging_buffer: Vec::new(), }); } diff --git a/src-tauri/src/image_loader.rs b/src-tauri/src/image_loader.rs index 44313f606a..03cbd7bb8f 100644 --- a/src-tauri/src/image_loader.rs +++ b/src-tauri/src/image_loader.rs @@ -6,7 +6,7 @@ use crate::file_management::{parse_virtual_path, read_file_mapped}; use crate::formats::is_raw_file; use crate::image_processing::ImageMetadata; use crate::image_processing::{ - apply_orientation, apply_srgb_to_linear, remove_raw_artifacts_and_enhance, + apply_orientation, apply_srgb_to_linear, downscale_f32_image, remove_raw_artifacts_and_enhance, }; use crate::mask_generation::{MaskDefinition, SubMask, generate_mask_bitmap}; use anyhow::{Context, Result, anyhow}; @@ -740,9 +740,21 @@ pub async fn load_image( let generation_tracker = state.load_image_generation.clone(); let cancel_token = Some((generation_tracker.clone(), my_generation)); + let is_same_image = { + let guard = state.original_image.lock().unwrap(); + guard.as_ref().and_then(|img| { + let (source_path, _) = parse_virtual_path(&path); + Some(img.path == source_path.to_string_lossy()) + }).unwrap_or(false) + }; + { *state.original_image.lock().unwrap() = None; *state.cached_preview.lock().unwrap() = None; + if !is_same_image { + *state.preview_cache.lock().unwrap() = None; + } + *state.gpu_processor.lock().unwrap() = None; *state.gpu_image_cache.lock().unwrap() = None; *state.full_warped_cache.lock().unwrap() = None; *state.full_transformed_cache.lock().unwrap() = None; @@ -781,7 +793,7 @@ pub async fn load_image( )); } - let (pristine_img, exif_data_loaded) = tokio::task::spawn_blocking(move || { + let (mut pristine_img, exif_data_loaded) = tokio::task::spawn_blocking(move || { if generation_tracker.load(Ordering::SeqCst) != my_generation { return Err("Load cancelled".to_string()); } @@ -835,6 +847,10 @@ pub async fn load_image( .await .map_err(|e| e.to_string())??; + if cfg!(target_os = "android") { + pristine_img = DynamicImage::ImageRgb8(pristine_img.to_rgb8()); + } + let arc_img = Arc::new(pristine_img); state.decoded_image_cache.lock().unwrap().insert( @@ -858,6 +874,20 @@ pub async fn load_image( let (orig_width, orig_height) = pristine_arc.dimensions(); + if cfg!(target_os = "android") { + let mut cache_lock = state.preview_cache.lock().unwrap(); + if cache_lock.is_none() { + let settings = load_settings(app_handle.clone()).unwrap_or_default(); + let cache_dim = settings.editor_preview_resolution.unwrap_or(1920).max(2560); + if orig_width > cache_dim || orig_height > cache_dim { + let cache_img = downscale_f32_image(&pristine_arc, cache_dim, cache_dim); + *cache_lock = Some(Arc::new(cache_img)); + } else { + *cache_lock = Some(Arc::clone(&pristine_arc)); + } + } + } + *state.original_image.lock().unwrap() = Some(LoadedImage { path, image: pristine_arc, diff --git a/src-tauri/src/inpainting.rs b/src-tauri/src/inpainting.rs index c366239095..85f63c60ba 100644 --- a/src-tauri/src/inpainting.rs +++ b/src-tauri/src/inpainting.rs @@ -37,6 +37,11 @@ pub async fn generate_manual_cleanup_patch( let composited = composite_patches_on_image(&base_image, &source_image_adjustments) .map_err(|e| format!("Failed to prepare source image: {}", e))?; + let composited = match composited { + DynamicImage::ImageRgb8(img) => DynamicImage::ImageRgba32F(DynamicImage::ImageRgb8(img).to_rgba32f()), + other => other, + }; + let source_image = if is_raw { apply_linear_to_srgb(composited) } else { @@ -346,6 +351,11 @@ pub async fn invoke_generative_replace_with_mask_def( let composited = composite_patches_on_image(&base_image, &source_image_adjustments) .map_err(|e| format!("Failed to prepare source image: {}", e))?; + let composited = match composited { + DynamicImage::ImageRgb8(img) => DynamicImage::ImageRgba32F(DynamicImage::ImageRgb8(img).to_rgba32f()), + other => other, + }; + let source_image = if is_raw { apply_linear_to_srgb(composited) } else { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc5d11af05..177d9d6ce5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ -#[cfg(not(all(target_os = "windows", target_arch = "aarch64")))] +#[cfg(not(any(target_os = "windows", target_arch = "aarch64", target_os = "android")))] use mimalloc::MiMalloc; -#[cfg(not(all(target_os = "windows", target_arch = "aarch64")))] +#[cfg(not(any(target_os = "windows", target_arch = "aarch64", target_os = "android")))] #[global_allocator] static GLOBAL: MiMalloc = MiMalloc; @@ -49,6 +49,7 @@ use std::thread; use std::borrow::Cow; use std::sync::{Arc, Mutex}; +use std::time::Instant; use std::time::Duration; use base64::{Engine as _, engine::general_purpose}; @@ -260,7 +261,7 @@ pub fn get_cached_full_warped_image( apply_cpu_default_raw_processing(cow_image.to_mut()); } - let warped_image = apply_geometry_warp(cow_image, js_adjustments).into_owned(); + let warped_image = apply_all_transformations(cow_image, js_adjustments).0.into_owned(); let warped_arc = Arc::new(warped_image); { @@ -276,7 +277,7 @@ async fn update_wgpu_transform( payload: WgpuTransformPayload, state: tauri::State<'_, AppState>, ) -> Result<(), String> { - let context = match state.gpu_context.lock().unwrap().as_ref() { + let context = match state.gpu_context.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { Some(c) => c.clone(), None => return Ok(()), }; @@ -324,7 +325,7 @@ fn process_preview_job( let fn_start = std::time::Instant::now(); let context = get_or_init_gpu_context(&state, app_handle)?; hydrate_adjustments(&state, &mut adjustments_json); - let adjustments_clone = adjustments_json; + let mut adjustments_clone = adjustments_json; let loaded_image_guard = state.original_image.lock().unwrap(); let loaded_image = loaded_image_guard @@ -368,8 +369,98 @@ fn process_preview_job( cached.scale, cached.unscaled_crop_offset, ) + } else if cfg!(target_os = "android") { + state.processing_active.store(true, Ordering::SeqCst); + *state.gpu_image_cache.lock().unwrap_or_else(|e| e.into_inner()) = None; + + let (orig_w, orig_h) = loaded_image.image.dimensions(); + + let has_patches = adjustments_clone + .get("aiPatches") + .and_then(|v| v.as_array()) + .is_some_and(|a| !a.is_empty()); + + let base_image = if has_patches { + let patched = composite_patches_on_image(&*loaded_image.image, &adjustments_clone) + .map_err(|e| format!("Failed to composite AI patches: {}", e))?; + if orig_w > preview_dim || orig_h > preview_dim { + downscale_f32_image(&patched, preview_dim, preview_dim) + } else { + patched + } + } else if orig_w > preview_dim || orig_h > preview_dim { + let settings = load_settings(app_handle.clone()).unwrap_or_default(); + let cache_dim = settings.editor_preview_resolution.unwrap_or(1920).max(2560); + if preview_dim > cache_dim { + // High zoom: go directly from original for full sharpness + downscale_f32_image(&*loaded_image.image, preview_dim, preview_dim) + } else { + // Normal zoom: use cache, then downscale to preview_dim + let cached = state.preview_cache.lock().unwrap().as_ref().map(Arc::clone); + match cached { + Some(cached_img) => { + downscale_f32_image(&cached_img, preview_dim, preview_dim) + } + None => { + let mut cache_lock = state.preview_cache.lock().unwrap(); + if let Some(cached_img) = cache_lock.as_ref().map(Arc::clone) { + drop(cache_lock); + downscale_f32_image(&cached_img, preview_dim, preview_dim) + } else { + let cache_img = downscale_f32_image(&*loaded_image.image, cache_dim, cache_dim); + let result = downscale_f32_image(&cache_img, preview_dim, preview_dim); + *cache_lock = Some(Arc::new(cache_img)); + result + } + } + } + } + } else { + (*loaded_image.image).clone() + }; + let downscale = base_image.width() as f32 / orig_w.max(1) as f32; + + // Scale crop values to preview resolution so apply_crop uses correct coords + if let Some(crop) = adjustments_clone + .get_mut("crop") + .and_then(|c| c.as_object_mut()) + { + if let Some(v) = crop.get("x").and_then(|v| v.as_f64()) { + crop["x"] = (v * downscale as f64).into(); + } + if let Some(v) = crop.get("y").and_then(|v| v.as_f64()) { + crop["y"] = (v * downscale as f64).into(); + } + if let Some(v) = crop.get("width").and_then(|v| v.as_f64()) { + crop["width"] = (v * downscale as f64).into(); + } + if let Some(v) = crop.get("height").and_then(|v| v.as_f64()) { + crop["height"] = (v * downscale as f64).into(); + } + } + + // Run transformations at preview resolution (CPU warp, rotation, flip, crop) + let (transformed, scaled_offset) = + apply_all_transformations(Cow::Owned(base_image), &adjustments_clone); + let transformed = transformed.into_owned(); + + // Convert crop offset back to full-res for mask generation + let unscaled_offset = ( + scaled_offset.0 / downscale, + scaled_offset.1 / downscale, + ); + + // Compute scale from final preview dimensions to original image + let new_scale = downscale; + + // Clear full_res caches since we're operating at preview resolution + *state.full_transformed_cache.lock().unwrap() = None; + + state.processing_active.store(false, Ordering::SeqCst); + + (Arc::new(transformed), new_scale, unscaled_offset) } else { - *state.gpu_image_cache.lock().unwrap() = None; + *state.gpu_image_cache.lock().unwrap_or_else(|e| e.into_inner()) = None; let (base, scale, offset) = generate_transformed_preview(&state, &loaded_image, &adjustments_clone, preview_dim)?; @@ -399,7 +490,7 @@ fn process_preview_job( }; if is_interactive && base_valid { - *state.gpu_image_cache.lock().unwrap() = None; + *state.gpu_image_cache.lock().unwrap_or_else(|e| e.into_inner()) = None; } small @@ -721,6 +812,8 @@ fn generate_uncropped_preview( .clone() .ok_or("No original image loaded")?; + let my_seq = state.uncropped_preview_seq.fetch_add(1, Ordering::SeqCst) + 1; + thread::spawn(move || { let state = app_handle.state::(); let path = loaded_image.path.clone(); @@ -730,20 +823,80 @@ fn generate_uncropped_preview( .get("aiPatches") .and_then(|v| v.as_array()) .is_some_and(|a| !a.is_empty()); - let patched_image = if has_patches { - Cow::Owned( - composite_patches_on_image(&loaded_image.image, &adjustments_clone).unwrap_or_else( - |e| { + let settings = load_settings(app_handle.clone()).unwrap_or_default(); + let preview_dim = settings.editor_preview_resolution.unwrap_or(1920); + + if state.processing_active.load(Ordering::SeqCst) { + return; + } + + let _guard = state.uncropped_preview_work_mutex.lock().unwrap(); + if state.uncropped_preview_seq.load(Ordering::SeqCst) != my_seq { + log::info!("Uncropped preview superseded before geometry, skipping"); + return; + } + + let geo_base = if cfg!(target_os = "android") && { + let (w, h) = loaded_image.image.dimensions(); + w > preview_dim || h > preview_dim + } { + if has_patches { + let patched = composite_patches_on_image(&loaded_image.image, &adjustments_clone) + .unwrap_or_else(|e| { eprintln!("Failed to composite patches for uncropped preview: {}", e); - loaded_image.image.as_ref().clone() - }, - ), - ) + (*loaded_image.image).clone() + }); + let (w, h) = patched.dimensions(); + if w > preview_dim || h > preview_dim { + let scaled = downscale_f32_image(&patched, preview_dim, preview_dim); + drop(patched); + scaled + } else { + patched + } + } else { + let (w, h) = loaded_image.image.dimensions(); + if w > preview_dim || h > preview_dim { + let cache_dim = settings.editor_preview_resolution.unwrap_or(1920).max(2560); + if preview_dim > cache_dim { + // High zoom: go directly from original for full sharpness + downscale_f32_image(&*loaded_image.image, preview_dim, preview_dim) + } else { + // Normal zoom: use cache, then downscale to preview_dim + let cached = state.preview_cache.lock().unwrap().as_ref().map(Arc::clone); + match cached { + Some(cached_img) => { + downscale_f32_image(&cached_img, preview_dim, preview_dim) + } + None => { + let mut cache_lock = state.preview_cache.lock().unwrap(); + if let Some(cached_img) = cache_lock.as_ref().map(Arc::clone) { + drop(cache_lock); + downscale_f32_image(&cached_img, preview_dim, preview_dim) + } else { + let cache_img = downscale_f32_image(&*loaded_image.image, cache_dim, cache_dim); + let result = downscale_f32_image(&cache_img, preview_dim, preview_dim); + *cache_lock = Some(Arc::new(cache_img)); + result + } + } + } + } + } else { + (*loaded_image.image).clone() + } + } + } else if has_patches { + composite_patches_on_image(&loaded_image.image, &adjustments_clone) + .unwrap_or_else(|e| { + eprintln!("Failed to composite patches for uncropped preview: {}", e); + (*loaded_image.image).clone() + }) } else { - Cow::Borrowed(loaded_image.image.as_ref()) + (*loaded_image.image).clone() }; - let warped_image = apply_geometry_warp(patched_image, &adjustments_clone); + let warped_image = apply_geometry_warp(Cow::Owned(geo_base), &adjustments_clone); let orientation_steps = adjustments_clone["orientationSteps"].as_u64().unwrap_or(0) as u8; let coarse_rotated_image = apply_coarse_rotation(warped_image, orientation_steps); @@ -753,26 +906,9 @@ fn generate_uncropped_preview( .unwrap_or(false); let flip_vertical = adjustments_clone["flipVertical"].as_bool().unwrap_or(false); - let flipped_image = + let processing_base = apply_flip(coarse_rotated_image, flip_horizontal, flip_vertical).into_owned(); - - let settings = load_settings(app_handle.clone()).unwrap_or_default(); - let preview_dim = settings.editor_preview_resolution.unwrap_or(1920); - - let (rotated_w, rotated_h) = flipped_image.dimensions(); - - let (processing_base, scale_for_gpu) = if rotated_w > preview_dim || rotated_h > preview_dim - { - let base = downscale_f32_image(&flipped_image, preview_dim, preview_dim); - let scale = if rotated_w > 0 { - base.width() as f32 / rotated_w as f32 - } else { - 1.0 - }; - (base, scale) - } else { - (flipped_image.clone(), 1.0) - }; + let scale_for_gpu = 1.0; let (preview_width, preview_height) = processing_base.dimensions(); @@ -802,6 +938,11 @@ fn generate_uncropped_preview( let lut_path = adjustments_clone["lutPath"].as_str(); let lut = lut_path.and_then(|p| lut_processing::get_or_load_lut(&state, p).ok()); + if state.uncropped_preview_seq.load(Ordering::SeqCst) != my_seq { + log::info!("Uncropped preview superseded, skipping GPU pipeline"); + return; + } + if let Ok(processed_image) = process_and_get_dynamic_image( &context, &state, @@ -822,9 +963,21 @@ fn generate_uncropped_preview( .encode_rgb(&rgb_pixels, width, height) { Ok(bytes) => { - let base64_str = general_purpose::STANDARD.encode(&bytes); - let data_url = format!("data:image/jpeg;base64,{}", base64_str); - let _ = app_handle.emit("preview-update-uncropped", data_url); + let should_emit = { + let mut last = state.uncropped_last_emit.lock().unwrap(); + let now = Instant::now(); + if now.duration_since(*last).as_millis() >= 500 { + *last = now; + true + } else { + false + } + }; + if should_emit { + let base64_str = general_purpose::STANDARD.encode(&bytes); + let data_url = format!("data:image/jpeg;base64,{}", base64_str); + let _ = app_handle.emit("preview-update-uncropped", data_url); + } } Err(e) => { log::error!("Failed to encode uncropped preview with mozjpeg-rs: {}", e); @@ -1551,8 +1704,9 @@ fn generate_preview_for_path( } }; + let geo_base = Cow::Owned(base_image); let (transformed_image, unscaled_crop_offset) = - apply_all_transformations(Cow::Borrowed(&base_image), &js_adjustments); + apply_all_transformations(geo_base, &js_adjustments); let (img_w, img_h) = transformed_image.dimensions(); let mask_definitions: Vec = js_adjustments .get("masks") @@ -2281,8 +2435,13 @@ pub fn run() { thumbnail_geometry_cache: Mutex::new(HashMap::new()), lens_db: Mutex::new(None), load_image_generation: Arc::new(AtomicUsize::new(0)), + uncropped_preview_seq: AtomicUsize::new(0), + uncropped_preview_work_mutex: Mutex::new(()), + uncropped_last_emit: Mutex::new(Instant::now()), full_warped_cache: Mutex::new(None), full_transformed_cache: Mutex::new(None), + preview_cache: Mutex::new(None), + processing_active: AtomicBool::new(false), decoded_image_cache: Mutex::new(DecodedImageCache::new(5)), thumbnail_manager: ThumbnailManager::new(), metadata_manager: MetadataManager::new(), diff --git a/src/App.tsx b/src/App.tsx index 09ab2fbdad..8b47ab2e90 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -623,6 +623,7 @@ function App() { previewJobIdRef={previewJobIdRef} latestRenderedJobIdRef={latestRenderedJobIdRef} currentResRef={currentResRef} + isAndroid={isAndroid} />
; latestRenderedJobIdRef: React.RefObject; currentResRef: React.RefObject; + isAndroid: boolean; } export default function ImageProcessingManager(props: Props) { @@ -13,7 +14,7 @@ export default function ImageProcessingManager(props: Props) { previewJobIdRef: props.previewJobIdRef, latestRenderedJobIdRef: props.latestRenderedJobIdRef, currentResRef: props.currentResRef, - }); + }, props.isAndroid); return null; } diff --git a/src/components/panel/Editor.tsx b/src/components/panel/Editor.tsx index b18b2f491f..60e1c97f49 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -2051,6 +2051,7 @@ export default function Editor({ onBackToLibrary, onContextMenu, transformWrappe liveRotation={liveRotation} transformState={transformState} hasRenderedFirstFrame={hasRenderedFirstFrame} + isAndroid={isAndroid} />
diff --git a/src/components/panel/editor/ImageCanvas.tsx b/src/components/panel/editor/ImageCanvas.tsx index 556c486bad..57160ed379 100644 --- a/src/components/panel/editor/ImageCanvas.tsx +++ b/src/components/panel/editor/ImageCanvas.tsx @@ -75,6 +75,7 @@ interface ImageCanvasProps { liveRotation?: number | null; transformState: { scale: number; positionX: number; positionY: number }; hasRenderedFirstFrame: boolean; + isAndroid?: boolean; } interface MaskOverlayProps { @@ -1184,6 +1185,7 @@ const ImageCanvas = memo( liveRotation, transformState, hasRenderedFirstFrame, + isAndroid, }: ImageCanvasProps) => { const [isCropViewVisible, setIsCropViewVisible] = useState(false); const cropImageRef = useRef(null); diff --git a/src/hooks/useImageProcessing.ts b/src/hooks/useImageProcessing.ts index adcb67df0b..d6c1c2d273 100644 --- a/src/hooks/useImageProcessing.ts +++ b/src/hooks/useImageProcessing.ts @@ -18,6 +18,7 @@ export function useImageProcessing( latestRenderedJobIdRef: React.RefObject; currentResRef: React.RefObject; }, + isAndroid: boolean, ) { const { previewJobIdRef, latestRenderedJobIdRef, currentResRef } = renderRefs; @@ -40,7 +41,11 @@ export function useImageProcessing( const inFlightCountRef = useRef(0); const pendingApplyRef = useRef<{ adjustments: Adjustments; targetRes?: number } | null>(null); + const lastFlushTimeRef = useRef(0); + const flushTimerRef = useRef | null>(null); const currentOriginalResRef = useRef(0); + const hiFiZoomMaxResRef = useRef(0); + const hiFiOriginalZoomMaxResRef = useRef(0); const dragIdleTimer = useRef | null>(null); const activeWaveformChannelRef = useRef(activeWaveformChannel); activeWaveformChannelRef.current = activeWaveformChannel; @@ -50,6 +55,12 @@ export function useImageProcessing( selectedImagePathRef.current = selectedImage?.path ?? null; }, [selectedImage?.path]); + useEffect(() => { + // Image changed: reset resolution refs so the new image starts at base res + currentResRef.current = 0; + currentOriginalResRef.current = 0; + }, [selectedImage?.path]); + const geometricAdjustmentsKey = useMemo(() => { if (!adjustments) return ''; const { crop, rotation, flipHorizontal, flipVertical, orientationSteps } = adjustments; @@ -122,6 +133,9 @@ export function useImageProcessing( async (currentAdjustments: Adjustments, dragging: boolean = false, targetRes?: number) => { const currentPath = selectedImage?.path; if (!currentPath) return; + if (isAndroid) { + console.warn(`[ZOOMDBG] executeApplyAdjustments targetRes=${targetRes} dragging=${dragging} currentRes=${currentResRef.current}`); + } const payload = structuredClone(currentAdjustments); const { patchesSentToBackend } = useEditorStore.getState(); @@ -274,6 +288,21 @@ export function useImageProcessing( if (inFlightCountRef.current >= 3) return; if (!pendingApplyRef.current) return; + if (isAndroid) { + const now = Date.now(); + const elapsed = now - lastFlushTimeRef.current; + if (elapsed < 33) { + if (!flushTimerRef.current) { + flushTimerRef.current = setTimeout(() => { + flushTimerRef.current = null; + flushPipeline(); + }, 200 - elapsed); + } + return; + } + lastFlushTimeRef.current = now; + } + const { adjustments, targetRes } = pendingApplyRef.current; pendingApplyRef.current = null; @@ -285,7 +314,7 @@ export function useImageProcessing( requestAnimationFrame(() => flushPipeline()); } }); - }, [executeApplyAdjustments]); + }, [executeApplyAdjustments, isAndroid]); const applyAdjustments = useCallback( (currentAdjustments: Adjustments, dragging: boolean = false, targetRes?: number) => { @@ -351,39 +380,43 @@ export function useImageProcessing( const requestHiFiZoom = useMemo( () => - debounce((currentAdjustments: Adjustments, targetRes: number) => { - if (targetRes > currentResRef.current) { - currentResRef.current = targetRes; - applyAdjustments(currentAdjustments, false, targetRes); + debounce((currentAdjustments: Adjustments, targetRes?: number) => { + const bestRes = isAndroid ? hiFiZoomMaxResRef.current : (targetRes || 0); + if (isAndroid) hiFiZoomMaxResRef.current = 0; + if (bestRes > currentResRef.current) { + currentResRef.current = bestRes; + applyAdjustments(currentAdjustments, false, bestRes); } }, 50), - [applyAdjustments, currentResRef], + [applyAdjustments, currentResRef, isAndroid], ); const requestHiFiOriginalZoom = useMemo( () => - debounce(async (currentAdjustments: Adjustments, targetRes: number) => { - if (targetRes > currentOriginalResRef.current) { + debounce(async (currentAdjustments: Adjustments, targetRes?: number) => { + const bestRes = isAndroid ? hiFiOriginalZoomMaxResRef.current : (targetRes || 0); + if (isAndroid) hiFiOriginalZoomMaxResRef.current = 0; + if (bestRes > currentOriginalResRef.current) { try { const base64Data: string = await invoke('generate_original_transformed_preview', { jsAdjustments: currentAdjustments, - targetResolution: targetRes, + targetResolution: bestRes, }); - currentOriginalResRef.current = targetRes; + currentOriginalResRef.current = bestRes; setEditor({ transformedOriginalUrl: base64Data }); } catch (e) { console.error('Failed to generate hi-fi original preview:', e); } } }, 200), - [setEditor], + [setEditor, isAndroid], ); useEffect(() => { - if (activeRightPanel === Panel.Crop && selectedImage?.isReady) { + if (activeRightPanel === Panel.Crop && selectedImage?.isReady && !isSliderDragging) { generateUncroppedPreview(adjustments); } - }, [adjustments, activeRightPanel, selectedImage?.isReady, generateUncroppedPreview]); + }, [adjustments, activeRightPanel, selectedImage?.isReady, isSliderDragging, generateUncroppedPreview]); useEffect(() => { if (selectedImage?.isReady && displaySize.width > 0 && !isSliderDragging) { @@ -395,7 +428,16 @@ export function useImageProcessing( const finalRes = Math.round(baseRes); if (finalRes > currentResRef.current) { - requestHiFiZoom(adjustments, finalRes); + if (isAndroid) { + hiFiZoomMaxResRef.current = Math.max(hiFiZoomMaxResRef.current, finalRes); + requestHiFiZoom(adjustments); + } else { + requestHiFiZoom(adjustments, finalRes); + } + } else if (finalRes < currentResRef.current * 0.7) { + // Zoomed out significantly — re-render at lower res to free cached high-res preview memory + currentResRef.current = finalRes; + applyAdjustments(adjustments, false, finalRes); } } return () => { @@ -484,7 +526,12 @@ export function useImageProcessing( if (showOriginal && selectedImage?.isReady && displaySize.width > 0 && !isSliderDragging) { let targetRes = calculateTargetRes(); if (targetRes > currentOriginalResRef.current) { - requestHiFiOriginalZoom(adjustments, targetRes); + if (isAndroid) { + hiFiOriginalZoomMaxResRef.current = Math.max(hiFiOriginalZoomMaxResRef.current, targetRes); + requestHiFiOriginalZoom(adjustments); + } else { + requestHiFiOriginalZoom(adjustments, targetRes); + } } } return () => {