diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 55168645d..8c9552236 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -20,6 +20,7 @@ dependencies = [ "half", "hex", "image", + "image-hdr", "image_hasher", "imageproc", "io", @@ -2967,6 +2968,19 @@ dependencies = [ "zune-jpeg 0.5.8", ] +[[package]] +name = "image-hdr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f401958e284ad5c7a124aa8cbd83e8072e91f7c04ca48c77e1f55d9b660206d" +dependencies = [ + "image", + "kamadak-exif", + "ndarray", + "rayon", + "thiserror 2.0.17", +] + [[package]] name = "image-webp" version = "0.2.4" @@ -4043,6 +4057,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "rawpointer", + "rayon", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 17400ab5b..23a71062d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -64,6 +64,7 @@ glam = "0.30.9" tauri-plugin-single-instance = "2.3.6" quick-xml = { version = "0.36", features = ["serialize"] } fuzzy-matcher = "0.3.7" +image-hdr = { version = "0.6.0", default-features = false } [build-dependencies] tauri-build = { version = "2.5", features = [] } diff --git a/src-tauri/src/exif_processing.rs b/src-tauri/src/exif_processing.rs index 3cb37466b..77235884f 100644 --- a/src-tauri/src/exif_processing.rs +++ b/src-tauri/src/exif_processing.rs @@ -3,14 +3,15 @@ use std::fs; use std::io::{BufReader, Cursor}; use std::path::Path; +use crate::formats::is_raw_file; use chrono::{DateTime, Utc}; +use exif::{Exif, Field, In, Value}; use little_exif::exif_tag::ExifTag; use little_exif::filetype::FileExtension; use little_exif::metadata::Metadata; use little_exif::rational::{iR64, uR64}; use rawler; - -use crate::formats::is_raw_file; +use rawler::decoders::RawMetadata; fn to_ur64(val: &exif::Rational) -> uR64 { uR64 { @@ -34,16 +35,105 @@ fn fmt_date_str(s: String) -> String { clean } +pub fn read_exif(file_bytes: &[u8]) -> Option { + let exifreader = exif::Reader::new(); + exifreader + .read_from_container(&mut Cursor::new(file_bytes)) + .ok() +} + +pub fn read_raw_metadata(file_bytes: &[u8]) -> Option { + let loader = rawler::RawLoader::new(); + let raw_source = rawler::rawsource::RawSource::new_from_slice(file_bytes); + let decoder = loader.get_decoder(&raw_source).ok()?; + decoder.raw_metadata(&raw_source, &Default::default()).ok() +} + +pub fn read_exposure_time_secs(path: &str, file_bytes: &[u8]) -> Option { + if is_raw_file(path) { + if let Some(meta) = read_raw_metadata(file_bytes) { + if let Some(r) = meta.exif.exposure_time { + return if r.d == 0 { + return None; + } else { + Some(r.n as f32 / r.d as f32) + }; + } else if let Some(r) = meta.exif.shutter_speed_value { + return if r.d == 0 { + None + } else { + Some(r.n as f32 / r.d as f32) + }; + } + } + } + + if let Some(exif) = read_exif(file_bytes) { + if let Some(exposure) = exif.get_field(exif::Tag::ExposureTime, In::PRIMARY) { + if let Value::Rational(ref r) = exposure.value { + if r.is_empty() { + return None; + } + + let val = r.get(0)?; + + return if val.denom == 0 { + None + } else { + Some(val.num as f32 / val.denom as f32) + }; + } + } else if let Some(shutter_speed) = + exif.get_field(exif::Tag::ShutterSpeedValue, In::PRIMARY) + { + if let Value::Rational(ref r) = shutter_speed.value { + if r.is_empty() { + return None; + } + + let val = r.get(0)?; + + return if val.denom == 0 { + None + } else { + Some(val.num as f32 / val.denom as f32) + }; + } + } + } + None +} + +pub fn read_iso(path: &str, file_bytes: &[u8]) -> Option { + if is_raw_file(path) { + if let Some(meta) = read_raw_metadata(file_bytes) { + if let Some(r) = meta.exif.iso_speed { + return Some(r); + } else if let Some(r) = meta.exif.iso_speed_ratings { + return Some(r as u32); + } + } + } + + if let Some(exif) = read_exif(file_bytes) { + if let Some(r) = exif.get_field(exif::Tag::ISOSpeed, In::PRIMARY) { + return r.value.get_uint(0); + } else if let Some(r) = exif.get_field(exif::Tag::PhotographicSensitivity, In::PRIMARY) { + return r.value.get_uint(0); + } + } + None +} + pub fn read_exif_data(path: &str, file_bytes: &[u8]) -> HashMap { if is_raw_file(path) { - if let Some(map) = extract_metadata(path, file_bytes) { + if let Some(map) = extract_metadata(file_bytes) { return map; } } let mut exif_data = HashMap::new(); - let exif_reader = exif::Reader::new(); - if let Ok(exif) = exif_reader.read_from_container(&mut Cursor::new(file_bytes)) { + if let Some(exif) = read_exif(file_bytes) { for field in exif.fields() { exif_data.insert( field.tag.to_string(), @@ -54,14 +144,12 @@ pub fn read_exif_data(path: &str, file_bytes: &[u8]) -> HashMap exif_data } -pub fn extract_metadata(path_str: &str, file_bytes: &[u8]) -> Option> { +pub fn extract_metadata(file_bytes: &[u8]) -> Option> { let mut map = HashMap::new(); - let exifreader = exif::Reader::new(); - - if let Ok(exif_obj) = exifreader.read_from_container(&mut Cursor::new(file_bytes)) { + if let Some(exif_obj) = read_exif(file_bytes) { for field in exif_obj.fields() { - match field.tag { + match field.tag { exif::Tag::ExposureTime => { if let exif::Value::Rational(ref v) = field.value { if !v.is_empty() { @@ -131,28 +219,16 @@ pub fn extract_metadata(path_str: &str, file_bytes: &[u8]) -> Option s, - Err(_) => return None, - }; - let decoder = match loader.get_decoder(&raw_source) { - Ok(d) => d, - Err(_) => return None, - }; - let metadata = match decoder.raw_metadata(&raw_source, &Default::default()) { - Ok(m) => m, - Err(_) => return None, - }; - + + let metadata = read_raw_metadata(file_bytes)?; + let exif = metadata.exif; let fmt_rat = |r: &rawler::formats::tiff::Rational| -> f32 { diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 4a5b88cba..2d41b3200 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -422,13 +422,12 @@ pub async fn read_exif_for_paths( .par_iter() .filter_map(|virtual_path| { let (source_path, _) = parse_virtual_path(virtual_path); - let source_path_str = source_path.to_string_lossy().to_string(); let exif_map = if let Ok(mmap) = read_file_mapped(&source_path) { - exif_processing::extract_metadata(&source_path_str, &mmap) + exif_processing::extract_metadata(&mmap) } else { let bytes = fs::read(&source_path).ok()?; - exif_processing::extract_metadata(&source_path_str, &bytes) + exif_processing::extract_metadata(&bytes) }; exif_map.map(|map| (virtual_path.clone(), map)) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c68979f40..3f83bb772 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -31,14 +31,16 @@ use std::collections::{HashMap, hash_map::DefaultHasher}; use std::fs; use std::hash::{Hash, Hasher}; use std::io::Cursor; +use std::io::Write; use std::panic; use std::path::{Path, PathBuf}; +use std::str::FromStr; use std::sync::Arc; +use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; use std::thread; -use std::io::Write; -use std::sync::Mutex; -use std::sync::mpsc::{self, Sender, Receiver}; +use std::time::Duration; use base64::{Engine as _, engine::general_purpose}; use image::codecs::jpeg::JpegEncoder; @@ -46,9 +48,12 @@ use image::{ DynamicImage, GenericImageView, GrayImage, ImageBuffer, ImageFormat, Luma, Rgb, RgbImage, Rgba, RgbaImage, imageops, }; +use image_hdr::hdr_merge_images; +use image_hdr::input::HDRInput; +use image_hdr::stretch::apply_histogram_stretch; use imageproc::drawing::draw_line_segment_mut; use imageproc::edges::canny; -use imageproc::hough::{detect_lines, LineDetectionOptions}; +use imageproc::hough::{LineDetectionOptions, detect_lines}; use rayon::prelude::*; use reqwest; use serde::{Deserialize, Serialize}; @@ -64,18 +69,17 @@ use crate::ai_processing::{ generate_image_embeddings, get_or_init_ai_models, run_sam_decoder, run_sky_seg_model, run_u2netp_model, }; -use crate::file_management::{ - AppSettings, load_settings, parse_virtual_path, - read_file_mapped, -}; +use crate::exif_processing::{read_exposure_time_secs, read_iso}; +use crate::file_management::{AppSettings, load_settings, parse_virtual_path, read_file_mapped}; use crate::formats::is_raw_file; use crate::image_loader::{ composite_patches_on_image, load_and_composite, load_base_image_from_bytes, }; use crate::image_processing::{ - Crop, GpuContext, ImageMetadata, apply_coarse_rotation, apply_crop, apply_flip, apply_rotation, - get_all_adjustments_from_json, get_or_init_gpu_context, process_and_get_dynamic_image, apply_unwarp_geometry, - downscale_f32_image, apply_cpu_default_raw_processing, GeometryParams, warp_image_geometry, apply_geometry_warp + Crop, GeometryParams, GpuContext, ImageMetadata, apply_coarse_rotation, + apply_cpu_default_raw_processing, apply_crop, apply_flip, apply_geometry_warp, apply_rotation, + apply_unwarp_geometry, downscale_f32_image, get_all_adjustments_from_json, + get_or_init_gpu_context, process_and_get_dynamic_image, warp_image_geometry, }; use crate::lut_processing::Lut; use crate::mask_generation::{AiPatchDefinition, MaskDefinition, generate_mask_bitmap}; @@ -125,6 +129,7 @@ pub struct AppState { ai_state: Mutex>, ai_init_lock: TokioMutex<()>, export_task_handle: Mutex>>, + hdr_result: Arc>>, panorama_result: Arc>>, denoise_result: Arc>>, negative_conversion_result: Arc>>, @@ -135,7 +140,7 @@ pub struct AppState { preview_worker_tx: Mutex>>, pub mask_cache: Mutex>, pub patch_cache: Mutex>, - pub geometry_cache: Mutex>, + pub geometry_cache: Mutex>, pub thumbnail_geometry_cache: Mutex>, pub lens_db: Mutex>, pub load_image_generation: Arc, @@ -253,7 +258,7 @@ const GEOMETRY_KEYS: &[&str] = &[ "transformRotate", "transformAspect", "transformScale", "transformXOffset", "transformYOffset", "lensDistortionAmount", "lensVignetteAmount", "lensTcaAmount", "lensDistortionParams", - "lensMaker", "lensModel", "lensDistortionEnabled", + "lensMaker", "lensModel", "lensDistortionEnabled", "lensTcaEnabled", "lensVignetteEnabled" ]; @@ -269,7 +274,7 @@ pub fn calculate_geometry_hash(adjustments: &serde_json::Value) -> u64 { for key in GEOMETRY_KEYS { if let Some(val) = adjustments.get(key) { key.hash(&mut hasher); - val.to_string().hash(&mut hasher); + val.to_string().hash(&mut hasher); } } @@ -452,11 +457,7 @@ fn generate_transformed_preview( let (full_res_w, full_res_h) = transformed_full_res.dimensions(); let final_preview_base = if full_res_w > final_preview_dim || full_res_h > final_preview_dim { - downscale_f32_image( - &transformed_full_res, - final_preview_dim, - final_preview_dim, - ) + downscale_f32_image(&transformed_full_res, final_preview_dim, final_preview_dim) } else { transformed_full_res }; @@ -625,8 +626,8 @@ fn apply_watermark( let (base_w, base_h) = base_image.dimensions(); let base_min_dim = base_w.min(base_h) as f32; - let watermark_scale_factor = (base_min_dim * (watermark_settings.scale / 100.0)) - / watermark_img.width().max(1) as f32; + let watermark_scale_factor = + (base_min_dim * (watermark_settings.scale / 100.0)) / watermark_img.width().max(1) as f32; let new_wm_w = (watermark_img.width() as f32 * watermark_scale_factor).round() as u32; let new_wm_h = (watermark_img.height() as f32 * watermark_scale_factor).round() as u32; @@ -666,9 +667,9 @@ fn apply_watermark( WatermarkAnchor::CenterLeft | WatermarkAnchor::Center | WatermarkAnchor::CenterRight => { (base_h as i64 - wm_h as i64) / 2 } - WatermarkAnchor::BottomLeft | WatermarkAnchor::BottomCenter | WatermarkAnchor::BottomRight => { - base_h as i64 - wm_h as i64 - spacing_pixels - } + WatermarkAnchor::BottomLeft + | WatermarkAnchor::BottomCenter + | WatermarkAnchor::BottomRight => base_h as i64 - wm_h as i64 - spacing_pixels, }; image::imageops::overlay(base_image, &final_watermark, x, y); @@ -685,16 +686,16 @@ pub fn get_cached_or_generate_mask( crop_offset: (f32, f32), ) -> Option { let mut hasher = DefaultHasher::new(); - + let def_json = serde_json::to_string(&def).unwrap_or_default(); def_json.hash(&mut hasher); - + width.hash(&mut hasher); height.hash(&mut hasher); scale.to_bits().hash(&mut hasher); crop_offset.0.to_bits().hash(&mut hasher); crop_offset.1.to_bits().hash(&mut hasher); - + let key = hasher.finish(); { @@ -709,7 +710,7 @@ pub fn get_cached_or_generate_mask( if let Some(img) = &generated { let mut cache = state.mask_cache.lock().unwrap(); if cache.len() > 50 { - cache.clear(); + cache.clear(); } cache.insert(key, img.clone()); } @@ -887,7 +888,7 @@ fn process_preview_job( fn start_preview_worker(app_handle: tauri::AppHandle) { let state = app_handle.state::(); let (tx, rx): (Sender, Receiver) = mpsc::channel(); - + *state.preview_worker_tx.lock().unwrap() = Some(tx); std::thread::spawn(move || { @@ -895,7 +896,7 @@ fn start_preview_worker(app_handle: tauri::AppHandle) { while let Ok(next_job) = rx.try_recv() { job = next_job; } - + let state = app_handle.state::(); if let Err(e) = process_preview_job(&app_handle, state, job) { log::error!("Preview worker error: {}", e); @@ -959,7 +960,7 @@ fn generate_uncropped_preview( let flip_horizontal = adjustments_clone["flipHorizontal"].as_bool().unwrap_or(false); let flip_vertical = adjustments_clone["flipVertical"].as_bool().unwrap_or(false); - + let flipped_image = apply_flip(coarse_rotated_image, flip_horizontal, flip_vertical); let settings = load_settings(app_handle.clone()).unwrap_or_default(); @@ -1095,7 +1096,7 @@ async fn preview_geometry_transform( cached_image } else { let context = get_or_init_gpu_context(&state)?; - + let original_image = { let guard = state.original_image.lock().unwrap(); let loaded = guard.as_ref().ok_or("No image loaded")?; @@ -1103,7 +1104,7 @@ async fn preview_geometry_transform( }; let settings = load_settings(app_handle.clone()).unwrap_or_default(); - let interactive_divisor = 1.5; + let interactive_divisor = 1.5; let final_preview_dim = settings.editor_preview_resolution.unwrap_or(1920); let target_dim = (final_preview_dim as f32 / interactive_divisor) as u32; @@ -1123,20 +1124,20 @@ async fn preview_geometry_transform( for key in GEOMETRY_KEYS { match *key { "transformScale" | - "lensDistortionAmount" | - "lensVignetteAmount" | + "lensDistortionAmount" | + "lensVignetteAmount" | "lensTcaAmount" => { obj.insert(key.to_string(), serde_json::json!(100.0)); }, "lensDistortionParams" | - "lensMaker" | + "lensMaker" | "lensModel" => { obj.insert(key.to_string(), serde_json::Value::Null); }, - "lensDistortionEnabled" | - "lensTcaEnabled" | + "lensDistortionEnabled" | + "lensTcaEnabled" | "lensVignetteEnabled" => { - obj.insert(key.to_string(), serde_json::json!(true)); + obj.insert(key.to_string(), serde_json::json!(true)); }, _ => { obj.insert(key.to_string(), serde_json::json!(0.0)); @@ -1192,14 +1193,14 @@ async fn preview_geometry_transform( let gray_image = flipped_image.to_luma8(); let mut visualization = flipped_image.to_rgba8(); let edges = canny(&gray_image, 50.0, 100.0); - + let min_dim = gray_image.width().min(gray_image.height()); - + let options = LineDetectionOptions { - vote_threshold: (min_dim as f32 * 0.24) as u32, + vote_threshold: (min_dim as f32 * 0.24) as u32, suppression_radius: 15, }; - + let lines = detect_lines(&edges, options); for line in lines { @@ -1208,9 +1209,9 @@ async fn preview_geometry_transform( let alignment_threshold = 0.5; let is_vertical = angle_norm < alignment_threshold || angle_norm > (180.0 - alignment_threshold); let is_horizontal = (angle_norm - 90.0).abs() < alignment_threshold; - + let color = if is_vertical || is_horizontal { - Rgba([0, 255, 0, 255]) + Rgba([0, 255, 0, 255]) } else { Rgba([255, 0, 0, 255]) }; @@ -1221,9 +1222,9 @@ async fn preview_geometry_transform( let b = theta_rad.sin(); let x0 = a * r; let y0 = b * r; - + let dist = (visualization.width().max(visualization.height()) * 2) as f32; - + let x1 = x0 + dist * (-b); let y1 = y0 + dist * (a); let x2 = x0 - dist * (-b); @@ -1250,9 +1251,9 @@ async fn preview_geometry_transform( }).await.map_err(|e| e.to_string())?; let mut buf = Cursor::new(Vec::new()); - + final_image.to_rgb8().write_with_encoder(JpegEncoder::new_with_quality(&mut buf, 75)).map_err(|e| e.to_string())?; - + let base64_str = general_purpose::STANDARD.encode(buf.get_ref()); Ok(format!("data:image/jpeg;base64,{}", base64_str)) } @@ -1565,8 +1566,8 @@ async fn batch_export_images( let progress_counter = Arc::new(AtomicUsize::new(0)); let available_cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); - let num_threads = (available_cores / 2).clamp(1, 4); - + let num_threads = (available_cores / 2).clamp(1, 4); + log::info!("Starting batch export. System cores: {}, Export threads: {}", available_cores, num_threads); let task = tokio::spawn(async move { @@ -1624,7 +1625,7 @@ async fn batch_export_images( } else { ImageMetadata::default() }; - let mut js_adjustments = metadata.adjustments; + let mut js_adjustments = metadata.adjustments; hydrate_adjustments(&state, &mut js_adjustments); let is_raw = is_raw_file(&source_path_str); @@ -2874,6 +2875,137 @@ async fn save_panorama( Ok(output_path.to_string_lossy().to_string()) } +#[tauri::command] +async fn merge_hdr( + paths: Vec, + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + if paths.len() < 2 { + return Err("Please select at least two images to merge.".to_string()); + } + + let hdr_result_handle = state.hdr_result.clone(); + + let images: Vec = paths + .iter() + .map(|path| { + let _ = app_handle.emit( + "hdr-progress", + format!( + "Processing '{}'", + Path::new(path) + .file_name() + .unwrap_or_default() + .to_string_lossy() + ), + ); + + let file_bytes = + fs::read(path).map_err(|e| format!("Failed to read image {}: {}", path, e))?; + let dynamic_image = load_base_image_from_bytes(&file_bytes, path, false, 2.5, None) + .map_err(|e| format!("Failed to load image {}: {}", path, e))?; + + let gains = match read_iso(&path, &file_bytes) { + None => { + return Err(format!( + "Image {} is missing 'PhotographicSensitivity' in EXIF data", + path + )); + } + Some(gains) => gains as f32, + }; + log::info!("Read image {} with gains: {}", path, gains); + let exposure = match read_exposure_time_secs(&path, &file_bytes) { + None => { + return Err(format!( + "Image {} is missing 'ExposureTime' in EXIF data", + path + )); + } + Some(exposure) => exposure, + }; + log::info!("Read image {} with exposure: {}", path, exposure); + + HDRInput::with_image(&dynamic_image, Duration::from_secs_f32(exposure), gains) + .map_err(|e| format!("Failed to prepare HDR input for image {}: {}", path, e)) + }) + .collect::, String>>()?; + + log::info!("Starting HDR merge of {} images", images.len()); + + let hdr_merged = hdr_merge_images(&mut images.into()).map_err(|e| e.to_string())?; + + log::info!("HDR merge completed"); + + let stretched = apply_histogram_stretch(&hdr_merged).map_err(|e| e.to_string())?; + + log::info!("Histogram stretch applied"); + + let mut buf = Cursor::new(Vec::new()); + + if let Err(e) = stretched.to_rgb8().write_to(&mut buf, ImageFormat::Png) { + return Err(format!("Failed to encode hdr preview: {}", e)); + } + + let base64_str = general_purpose::STANDARD.encode(buf.get_ref()); + let final_base64 = format!("data:image/png;base64,{}", base64_str); + + let _ = app_handle.emit("hdr-progress", "Creating preview..."); + + *hdr_result_handle.lock().unwrap() = Some(stretched); + + let _ = app_handle.emit( + "hdr-complete", + serde_json::json!({ + "base64": final_base64, + }), + ); + Ok(()) +} + +#[tauri::command] +async fn save_hdr( + first_path_str: String, + state: tauri::State<'_, AppState>, +) -> Result { + let hdr_image = state.hdr_result.lock().unwrap().take().ok_or_else(|| { + "No hdr image found in memory to save. It might have already been saved.".to_string() + })?; + + let (first_path, _) = parse_virtual_path(&first_path_str); + let parent_dir = first_path + .parent() + .ok_or_else(|| "Could not determine parent directory of the first image.".to_string())?; + let stem = first_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("hdr"); + + let (output_filename, image_to_save): (String, DynamicImage) = if hdr_image.color().has_alpha() + { + ( + format!("{}_Hdr.png", stem), + DynamicImage::ImageRgba8(hdr_image.to_rgba8()), + ) + } else if hdr_image.as_rgb32f().is_some() { + (format!("{}_Hdr.tiff", stem), hdr_image) + } else { + ( + format!("{}_Hdr.png", stem), + DynamicImage::ImageRgb8(hdr_image.to_rgb8()), + ) + }; + + let output_path = parent_dir.join(output_filename); + + image_to_save + .save(&output_path) + .map_err(|e| format!("Failed to save hdr image: {}", e))?; + + Ok(output_path.to_string_lossy().to_string()) +} + #[tauri::command] async fn apply_denoising( path: String, @@ -2928,7 +3060,7 @@ async fn save_denoised_image( let (output_filename, image_to_save): (String, DynamicImage) = if is_raw { let filename = format!("{}_Denoised.tiff", stem); - (filename, denoised_image) + (filename, denoised_image) } else { let filename = format!("{}_Denoised.png", stem); (filename, DynamicImage::ImageRgb8(denoised_image.to_rgb8())) @@ -3167,11 +3299,7 @@ fn setup_logging(app_handle: &tauri::AppHandle) { || "at an unknown location".to_string(), |loc| format!("at {}:{}:{}", loc.file(), loc.line(), loc.column()), ); - log::error!( - "PANIC! {} - {}", - location, - message.trim() - ); + log::error!("PANIC! {} - {}", location, message.trim()); })); log::info!( @@ -3182,10 +3310,7 @@ fn setup_logging(app_handle: &tauri::AppHandle) { #[tauri::command] fn get_log_file_path(app_handle: tauri::AppHandle) -> Result { - let log_dir = app_handle - .path() - .app_log_dir() - .map_err(|e| e.to_string())?; + let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?; let log_file_path = log_dir.join("app.log"); Ok(log_file_path.to_string_lossy().to_string()) } @@ -3293,7 +3418,7 @@ fn main() { log::info!("Applied Linux GPU optimizations."); } } - + start_preview_worker(app_handle.clone()); let window_cfg = app.config().app.windows.get(0).unwrap().clone(); @@ -3323,6 +3448,7 @@ fn main() { ai_state: Mutex::new(None), ai_init_lock: TokioMutex::new(()), export_task_handle: Mutex::new(None), + hdr_result: Arc::new(Mutex::new(None)), panorama_result: Arc::new(Mutex::new(None)), denoise_result: Arc::new(Mutex::new(None)), negative_conversion_result: Arc::new(Mutex::new(None)), @@ -3365,6 +3491,8 @@ fn main() { save_collage, stitch_panorama, save_panorama, + merge_hdr, + save_hdr, apply_denoising, save_denoised_image, load_and_parse_lut, @@ -3448,4 +3576,4 @@ fn main() { _ => {} } }); -} \ No newline at end of file +} diff --git a/src-tauri/src/panorama_stitching.rs b/src-tauri/src/panorama_stitching.rs index cba7a8aa2..eff593c99 100644 --- a/src-tauri/src/panorama_stitching.rs +++ b/src-tauri/src/panorama_stitching.rs @@ -46,7 +46,10 @@ pub struct MatchInfo { pub inliers: usize, } -pub fn stitch_images(image_paths: Vec, app_handle: AppHandle) -> Result { +pub fn stitch_images( + image_paths: Vec, + app_handle: AppHandle, +) -> Result { if image_paths.len() < 2 { return Err("At least two images are required for a panorama.".to_string()); } diff --git a/src/App.tsx b/src/App.tsx index ce59d589d..5cbfe97e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -121,6 +121,7 @@ import { CullingSuggestions, } from './components/ui/AppProperties'; import { ChannelConfig } from './components/adjustments/Curves'; +import HdrModal from './components/modals/HdrModal'; const CLERK_PUBLISHABLE_KEY = 'pk_test_YnJpZWYtc2Vhc25haWwtMTIuY2xlcmsuYWNjb3VudHMuZGV2JA'; // local dev key @@ -167,6 +168,14 @@ interface PanoramaModalState { stitchingSourcePaths: Array; } +interface HdrModalState { + error: string | null; + finalImageBase64: string | null; + isOpen: boolean; + progressMessage: string | null; + stitchingSourcePaths: Array; +} + interface DenoiseModalState { isOpen: boolean; isProcessing: boolean; @@ -426,6 +435,13 @@ function App() { progressMessage: '', stitchingSourcePaths: [], }); + const [hdrModalState, setHdrModalState] = useState({ + error: null, + finalImageBase64: null, + isOpen: false, + progressMessage: '', + stitchingSourcePaths: [], + }); const [negativeModalState, setNegativeModalState] = useState({ isOpen: false, targetPath: null, @@ -1260,7 +1276,7 @@ function App() { payload.aiPatches.forEach((p: any) => { if (p.id && p.patchData && !p.isLoading) { if (patchesSentToBackend.current.has(p.id)) { - p.patchData = null; + p.patchData = null; } else { patchesSentToBackend.current.add(p.id); } @@ -1285,9 +1301,9 @@ function App() { } try { - await invoke(Invokes.ApplyAdjustments, { - jsAdjustments: payload, - isInteractive: dragging + await invoke(Invokes.ApplyAdjustments, { + jsAdjustments: payload, + isInteractive: dragging }); } catch (err) { console.error('Failed to invoke apply_adjustments:', err); @@ -1477,12 +1493,12 @@ function App() { if (settings.lastRootPath) { const root = settings.lastRootPath; const currentPath = settings.lastFolderState?.currentFolderPath || root; - + const command = settings.libraryViewMode === LibraryViewMode.Recursive ? Invokes.ListImagesRecursive : Invokes.ListImagesInDir; - + preloadedDataRef.current = { rootPath: root, currentPath: currentPath, @@ -1925,7 +1941,7 @@ function App() { } applyAdjustments.cancel(); debouncedSave.cancel(); - patchesSentToBackend.current.clear(); + patchesSentToBackend.current.clear(); setSelectedImage({ exif: null, @@ -1957,7 +1973,7 @@ function App() { setActiveMaskContainerId(null); setActiveAiPatchContainerId(null); setActiveAiSubMaskId(null); - setIsWbPickerActive(false); + setIsWbPickerActive(false); if (transformWrapperRef.current) { transformWrapperRef.current.resetTransform(0); @@ -1998,7 +2014,7 @@ function App() { .slice(0, currentIndex) .reverse() .find((img) => !pathsToDelete.includes(img.path)); - + if (prevCandidate) { nextImagePath = prevCandidate.path; } @@ -2522,7 +2538,7 @@ function App() { [originalSize, baseRenderSize, handleFullResolutionLogic, adjustments.orientationSteps], ); - const isAnyModalOpen = + const isAnyModalOpen = isCreateFolderModalOpen || isRenameFolderModalOpen || isRenameFileModalOpen || @@ -2738,7 +2754,7 @@ function App() { if (isEffectActive) { const payload = event.payload; const isObject = typeof payload === 'object' && payload !== null; - + setDenoiseModalState((prev) => ({ ...prev, isProcessing: false, @@ -2852,6 +2868,52 @@ function App() { }; }, []); + useEffect(() => { + let isEffectActive = true; + + const unlistenProgress = listen('hdr-progress', (event: any) => { + if (isEffectActive) { + setHdrModalState((prev: HdrModalState) => ({ + ...prev, + error: null, + finalImageBase64: null, + isOpen: true, + progressMessage: event.payload, + })); + } + }); + + const unlistenComplete = listen('hdr-complete', (event: any) => { + if (isEffectActive) { + const { base64 } = event.payload; + setHdrModalState((prev: HdrModalState) => ({ + ...prev, + error: null, + finalImageBase64: base64, + progressMessage: 'Hdr Ready', + })); + } + }); + + const unlistenError = listen('hdr-error', (event: any) => { + if (isEffectActive) { + setHdrModalState((prev: HdrModalState) => ({ + ...prev, + error: String(event.payload), + finalImageBase64: null, + progressMessage: 'An error occurred.', + })); + } + }); + + return () => { + isEffectActive = false; + unlistenProgress.then((f: any) => f()); + unlistenComplete.then((f: any) => f()); + unlistenError.then((f: any) => f()); + }; + }, []); + useEffect(() => { let isEffectActive = true; @@ -2913,26 +2975,46 @@ function App() { } }; + const handleSaveHdr = async (): Promise => { + if (hdrModalState.stitchingSourcePaths.length === 0) { + const err = 'Source paths for HDR not found.'; + setHdrModalState((prev: HdrModalState) => ({ ...prev, error: err })); + throw new Error(err); + } + + try { + const savedPath: string = await invoke(Invokes.SaveHdr, { + firstPathStr: hdrModalState.stitchingSourcePaths[0], + }); + await refreshImageList(); + return savedPath; + } catch (err) { + console.error('Failed to save HDR image:', err); + setHdrModalState((prev: HdrModalState) => ({ ...prev, error: String(err) })); + throw err; + } + } + const handleApplyDenoise = useCallback(async (intensity: number) => { if (!denoiseModalState.targetPath) return; - - setDenoiseModalState(prev => ({ - ...prev, - isProcessing: true, - error: null, - progressMessage: "Starting engine..." + + setDenoiseModalState(prev => ({ + ...prev, + isProcessing: true, + error: null, + progressMessage: "Starting engine..." })); - + try { - await invoke(Invokes.ApplyDenoising, { + await invoke(Invokes.ApplyDenoising, { path: denoiseModalState.targetPath, - intensity: intensity + intensity: intensity }); } catch (err) { - setDenoiseModalState(prev => ({ - ...prev, - isProcessing: false, - error: String(err) + setDenoiseModalState(prev => ({ + ...prev, + isProcessing: false, + error: String(err) })); } }, [denoiseModalState.targetPath]); @@ -2967,7 +3049,7 @@ function App() { (currentAdjustments) => { applyAdjustments(currentAdjustments, true); }, - 100, + 100, { leading: true, trailing: true } ), [applyAdjustments] @@ -2988,7 +3070,7 @@ function App() { if (isSliderDragging) { debouncedApplyAdjustments.cancel(); - + const livePreviewsEnabled = appSettings?.enableLivePreviews !== false; const idleTimeoutDuration = livePreviewsEnabled ? 150 : 50; @@ -3011,12 +3093,12 @@ function App() { debouncedApplyAdjustments.cancel(); }; }, [ - adjustments, - selectedImage?.path, - selectedImage?.isReady, - isSliderDragging, - applyAdjustments, - debouncedApplyAdjustments, + adjustments, + selectedImage?.path, + selectedImage?.isReady, + isSliderDragging, + applyAdjustments, + debouncedApplyAdjustments, throttledInteractiveUpdate, debouncedSave, appSettings?.enableLivePreviews @@ -3081,7 +3163,7 @@ function App() { let preloadedImages: ImageFile[] | undefined = undefined; if ( - preloadedDataRef.current.currentPath === pathToSelect && + preloadedDataRef.current.currentPath === pathToSelect && preloadedDataRef.current.images ) { try { @@ -3398,12 +3480,12 @@ function App() { const originalAspectRatio = selectedImage.width && selectedImage.height ? selectedImage.width / selectedImage.height : null; - - resetAdjustmentsHistory({ - ...INITIAL_ADJUSTMENTS, + + resetAdjustmentsHistory({ + ...INITIAL_ADJUSTMENTS, aspectRatio: originalAspectRatio, - rating: currentRating, - aiPatches: [] + rating: currentRating, + aiPatches: [] }); } }) @@ -3717,6 +3799,7 @@ function App() { const cullLabel = isSingleSelection ? 'Cull Image' : `Cull ${selectionCount} Images`; const collageLabel = isSingleSelection ? 'Frame Image' : 'Create Collage'; const stitchLabel = `Stitch Panorama`; + const mergeLabel = `Merge to HDR`; const handleCreateVirtualCopy = async (sourcePath: string) => { try { @@ -3858,7 +3941,7 @@ function App() { { label: 'Convert Negative', icon: SquaresExclude, - disabled: !isSingleSelection, + disabled: !isSingleSelection, onClick: () => { setNegativeModalState({ isOpen: true, @@ -3888,6 +3971,28 @@ function App() { }); }, }, + { + disabled: selectionCount < 2 || selectionCount > 9, + icon: Images, + label: mergeLabel, + onClick: () => { + setHdrModalState({ + error: null, + finalImageBase64: null, + isOpen: true, + progressMessage: 'Starting hdr process...', + stitchingSourcePaths: finalSelection, + }); + invoke(Invokes.MergeHdr, { paths: finalSelection }).catch((err) => { + setHdrModalState((prev: HdrModalState) => ({ + ...prev, + error: String(err), + isOpen: true, + progressMessage: 'Failed to start.', + })); + }); + }, + }, { icon: LayoutTemplate, label: collageLabel, @@ -4691,7 +4796,26 @@ function App() { onSave={handleSavePanorama} progressMessage={panoramaModalState.progressMessage} /> - + setHdrModalState({ + isOpen: false, + progressMessage: "", + finalImageBase64: null, + error: null, + stitchingSourcePaths: [], + }) + } + onOpenFile={(path: string) => { + handleImageSelect(path); + }} + onSave={handleSaveHdr} + progressMessage={hdrModalState.progressMessage} + /> + setNegativeModalState(prev => ({ ...prev, isOpen: false }))} selectedImagePath={negativeModalState.targetPath} @@ -4703,7 +4827,7 @@ function App() { }); }} /> - setDenoiseModalState(prev => ({ ...prev, isOpen: false }))} onDenoise={handleApplyDenoise} diff --git a/src/components/modals/HdrModal.tsx b/src/components/modals/HdrModal.tsx new file mode 100644 index 000000000..be40b2094 --- /dev/null +++ b/src/components/modals/HdrModal.tsx @@ -0,0 +1,188 @@ +import { useState, useEffect, useCallback } from 'react'; +import { CheckCircle, XCircle, Loader2, Save } from 'lucide-react'; +import Button from '../ui/Button'; + +interface HdrModalProps { + error: string | null; + finalImageBase64: string | null; + isOpen: boolean; + onClose(): void; + onOpenFile(path: string): void; + onSave(): Promise; + progressMessage: string | null; +} + +export default function HdrModal({ + error, + finalImageBase64, + isOpen, + onClose, + onOpenFile, + onSave, + progressMessage, + }: HdrModalProps) { + const [isSaving, setIsSaving] = useState(false); + const [savedPath, setSavedPath] = useState(null); + const [isMounted, setIsMounted] = useState(false); + const [show, setShow] = useState(false); + + useEffect(() => { + if (isOpen) { + setIsMounted(true); + const timer = setTimeout(() => setShow(true), 10); + return () => clearTimeout(timer); + } else { + setShow(false); + const timer = setTimeout(() => { + setIsMounted(false); + setIsSaving(false); + setSavedPath(null); + }, 300); + return () => clearTimeout(timer); + } + }, [isOpen]); + + const handleClose = useCallback(() => { + if (isSaving) { + return; + } + onClose(); + }, [onClose, isSaving]); + + const handleSave = async () => { + setIsSaving(true); + try { + const path = await onSave(); + setSavedPath(path); + } catch (e) { + // Error handling can be added here if needed + } finally { + setIsSaving(false); + } + }; + + const handleOpen = () => { + if (savedPath) { + onOpenFile(savedPath); + handleClose(); + } + }; + + const handleKeyDown = useCallback( + (e: any) => { + if (e.key === 'Escape') { + handleClose(); + } + }, + [handleClose], + ); + + const renderContent = () => { + if (error) { + return ( + <> + +

HDR Failed

+

+ {String(error)} +

+ + ); + } + + if (finalImageBase64) { + return ( + <> + {savedPath && ( + <> + +

HDR Saved!

+ + )} +
+ Merged HDR +
+ + ); + } + + return ( + <> +
+ +
+

Merging HDR

+

{progressMessage}

+ + ); + }; + + const renderButtons = () => { + if (error) { + return ( + + ); + } + if (savedPath) { + return ( + <> + + + + ); + } + if (finalImageBase64) { + return ( + <> + + + + ); + } + return null; + }; + + if (!isMounted) { + return null; + } + + return ( +
+
e.stopPropagation()} + onKeyDown={handleKeyDown} + tabIndex={-1} + > +
+ {renderContent()} +
{renderButtons()}
+
+
+
+ ); +} diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 7c34e94fb..e54fb3d1d 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -66,12 +66,14 @@ export enum Invokes { SaveCollage = 'save_collage', SaveDenoisedImage = 'save_denoised_image', SavePanorama = 'save_panorama', + SaveHdr = 'save_hdr', SavePresets = 'save_presets', SaveSettings = 'save_settings', SetColorLabelForPaths = 'set_color_label_for_paths', ShowInFinder = 'show_in_finder', StartBackgroundIndexing = 'start_background_indexing', StitchPanorama = 'stitch_panorama', + MergeHdr = 'merge_hdr', TestAIConnectorConnection = 'test_ai_connector_connection', UpdateWindowEffect = 'update_window_effect', FetchCommunityPresets = 'fetch_community_presets',