From 30d0355c9625e3c64415ebb5e5be0f999382f0ce Mon Sep 17 00:00:00 2001 From: SimonIT Date: Fri, 28 Nov 2025 15:07:47 +0100 Subject: [PATCH 01/10] Initial hdr try --- src-tauri/Cargo.lock | 34 +++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/main.rs | 121 +++++++++++++++++++++++----- src/App.tsx | 84 +++++++++++++++++++ src/components/ui/AppProperties.tsx | 1 + 5 files changed, 219 insertions(+), 22 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c73987f14..0e0e32158 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -19,6 +19,7 @@ dependencies = [ "half", "hex", "image", + "image-hdr", "image_hasher", "imageproc", "io", @@ -28,7 +29,7 @@ dependencies = [ "memmap2", "mimalloc", "nalgebra 0.34.1", - "ndarray", + "ndarray 0.15.6", "num_cpus", "once_cell", "ort", @@ -2921,6 +2922,19 @@ dependencies = [ "zune-jpeg", ] +[[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 0.16.1", + "rayon", + "thiserror 2.0.17", +] + [[package]] name = "image-webp" version = "0.2.4" @@ -4008,6 +4022,22 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", + "rayon", +] + [[package]] name = "ndk" version = "0.9.0" @@ -4610,7 +4640,7 @@ dependencies = [ "lazy_static", "libc", "libloading 0.7.4", - "ndarray", + "ndarray 0.15.6", "tar", "thiserror 1.0.69", "tracing", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 70f083a90..49d669dfd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -62,6 +62,7 @@ half = { version = "2.7.1", features = ["bytemuck"] } exr = "1.73.0" glam = "0.30.9" tauri-plugin-single-instance = "2.3.6" +image-hdr = { version = "0.6.0", default-features = false } [build-dependencies] tauri-build = { version = "2.4", features = [] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 30c689f6f..82b5ca408 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -34,6 +34,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; use std::io::Write; use std::sync::Mutex; +use std::time::Duration; use base64::{Engine as _, engine::general_purpose}; use chrono::{DateTime, Utc}; @@ -42,6 +43,10 @@ use image::{ DynamicImage, GenericImageView, GrayImage, ImageBuffer, ImageFormat, Luma, Rgb, RgbImage, Rgba, RgbaImage, imageops, }; +use image_hdr::exif::{get_exif_data, get_exposures, get_gains}; +use image_hdr::hdr_merge_images; +use image_hdr::input::HDRInput; +use image_hdr::stretch::apply_histogram_stretch; use little_exif::exif_tag::ExifTag; use little_exif::filetype::FileExtension; use little_exif::metadata::Metadata; @@ -70,9 +75,9 @@ 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, - downscale_f32_image, apply_cpu_default_raw_processing, + Crop, GpuContext, ImageMetadata, apply_coarse_rotation, apply_cpu_default_raw_processing, + apply_crop, apply_flip, apply_rotation, downscale_f32_image, get_all_adjustments_from_json, + get_or_init_gpu_context, process_and_get_dynamic_image, }; use crate::lut_processing::Lut; use crate::mask_generation::{AiPatchDefinition, MaskDefinition, generate_mask_bitmap}; @@ -109,6 +114,7 @@ pub struct AppState { ai_state: Mutex>, ai_init_lock: TokioMutex<()>, export_task_handle: Mutex>>, + hdr_result: Arc>>, panorama_result: Arc>>, indexing_task_handle: Mutex>>, pub lut_cache: Mutex>>, @@ -320,11 +326,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 }; @@ -475,8 +477,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; @@ -516,9 +518,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); @@ -1455,7 +1457,9 @@ async fn estimate_batch_export_size( let mask_bitmaps: Vec, Vec>> = mask_definitions .iter() - .filter_map(|def| generate_mask_bitmap(def, preview_w, preview_h, 1.0, unscaled_crop_offset)) + .filter_map(|def| { + generate_mask_bitmap(def, preview_w, preview_h, 1.0, unscaled_crop_offset) + }) .collect(); let mut all_adjustments = get_all_adjustments_from_json(&js_adjustments, is_raw); @@ -2241,6 +2245,85 @@ async fn stitch_panorama( } } +#[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() + ), + ); + println!(" - Processing '{}'", path); + + let file_bytes = + fs::read(path).map_err(|e| format!("Failed to read image {}: {}", path, e)).unwrap(); + let dynamic_image = + crate::image_loader::load_base_image_from_bytes(&file_bytes, path, false) + .map_err(|e| format!("Failed to load image {}: {}", path, e)).unwrap(); + + println!("Read image with dimensions: {}x{}", dynamic_image.width(), dynamic_image.height()); + + let exif = get_exif_data(&file_bytes).unwrap(); + println!("Read image with exif:"); + let gains = get_gains(&exif).unwrap_or(1.0); + println!("Read image with gains: {}x{}", gains, dynamic_image.width()); + let exposure = get_exposures(&exif).unwrap_or(1.0); + println!("Read image with exposures: {}x{}", exposure, dynamic_image.width()); + + HDRInput::with_image(&dynamic_image, Duration::from_secs_f32(exposure), gains).unwrap() + }) + .collect(); + + println!("Starting HDR merge of {} images", images.len()); + + let hdr_merged = hdr_merge_images(&mut images.into()).map_err(|e| e.to_string())?; + + println!("HDR merge completed"); + + let stretched = apply_histogram_stretch(&hdr_merged).map_err(|e| e.to_string())?; + + println!("Histogram stretch applied"); + + let mut buf = Cursor::new(Vec::new()); + + if let Err(e) = stretched.write_to(&mut buf, ImageFormat::Png) { + return Err(format!("Failed to encode panorama 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.to_rgb8()); + + let _ = app_handle.emit( + "hdr-complete", + serde_json::json!({ + "base64": final_base64, + }), + ); + Ok(()) +} + #[tauri::command] async fn fetch_community_presets() -> Result, String> { let client = reqwest::Client::new(); @@ -2635,11 +2718,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!( @@ -2774,6 +2853,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)), indexing_task_handle: Mutex::new(None), lut_cache: Mutex::new(HashMap::new()), @@ -2804,6 +2884,7 @@ fn main() { get_supported_file_types, save_collage, stitch_panorama, + merge_hdr, save_panorama, load_and_parse_lut, fetch_community_presets, @@ -2873,4 +2954,4 @@ fn main() { } } }); -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index 59c12732a..24b8d0381 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -160,6 +160,14 @@ interface PanoramaModalState { stitchingSourcePaths: Array; } +interface HdrModalState { + error: string | null; + finalImageBase64: string | null; + isOpen: boolean; + progressMessage: string | null; + stitchingSourcePaths: Array; +} + interface CullingModalState { isOpen: boolean; suggestions: CullingSuggestions | null; @@ -357,6 +365,13 @@ function App() { progressMessage: '', stitchingSourcePaths: [], }); + const [hdrModalState, setHdrModalState] = useState({ + error: null, + finalImageBase64: null, + isOpen: false, + progressMessage: '', + stitchingSourcePaths: [], + }); const [cullingModalState, setCullingModalState] = useState({ isOpen: false, suggestions: null, @@ -2430,6 +2445,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; @@ -3084,6 +3145,7 @@ function App() { const cullLabel = isSingleSelection ? 'Cull Image' : `Cull Images`; const collageLabel = isSingleSelection ? 'Create Collage' : `Create Collage`; const stitchLabel = `Stitch Panorama`; + const mergeLabel = `Merge to HDR`; const handleCreateVirtualCopy = async (sourcePath: string) => { try { @@ -3204,6 +3266,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, diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index d316be12e..ccd44d714 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -68,6 +68,7 @@ export enum Invokes { ShowInFinder = 'show_in_finder', StartBackgroundIndexing = 'start_background_indexing', StitchPanorama = 'stitch_panorama', + MergeHdr = 'merge_hdr', TestComfyuiConnection = 'test_comfyui_connection', UpdateWindowEffect = 'update_window_effect', FetchCommunityPresets = 'fetch_community_presets', From 5777659a612f011b44e0a3bac4edf4c603a1f444 Mon Sep 17 00:00:00 2001 From: SimonIT Date: Thu, 11 Dec 2025 10:23:24 +0100 Subject: [PATCH 02/10] Add Hdr Modal and catch not supported exif --- src-tauri/src/main.rs | 48 ++++--- src/App.tsx | 39 ++++++ src/components/modals/HdrModal.tsx | 188 ++++++++++++++++++++++++++++ src/components/ui/AppProperties.tsx | 1 + 4 files changed, 260 insertions(+), 16 deletions(-) create mode 100644 src/components/modals/HdrModal.tsx diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ec98a9b28..aa5aa8205 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -38,15 +38,16 @@ use std::time::Duration; use base64::{Engine as _, engine::general_purpose}; use chrono::{DateTime, Utc}; +use exif::{Exif, In, Tag}; use image::codecs::jpeg::JpegEncoder; use image::{ DynamicImage, GenericImageView, GrayImage, ImageBuffer, ImageFormat, Luma, Rgb, RgbImage, Rgba, RgbaImage, imageops, }; use image_hdr::exif::{get_exif_data, get_exposures, get_gains}; -use image_hdr::hdr_merge_images; use image_hdr::input::HDRInput; use image_hdr::stretch::apply_histogram_stretch; +use image_hdr::{Error, hdr_merge_images}; use little_exif::exif_tag::ExifTag; use little_exif::filetype::FileExtension; use little_exif::metadata::Metadata; @@ -2369,7 +2370,7 @@ async fn merge_hdr( let hdr_result_handle = state.hdr_result.clone(); - let images : Vec = paths + let images: Vec = paths .iter() .map(|path| { let _ = app_handle.emit( @@ -2384,20 +2385,36 @@ async fn merge_hdr( ); println!(" - Processing '{}'", path); - let file_bytes = - fs::read(path).map_err(|e| format!("Failed to read image {}: {}", path, e)).unwrap(); + let file_bytes = fs::read(path) + .map_err(|e| format!("Failed to read image {}: {}", path, e)) + .unwrap(); let dynamic_image = - crate::image_loader::load_base_image_from_bytes(&file_bytes, path, false) - .map_err(|e| format!("Failed to load image {}: {}", path, e)).unwrap(); - - println!("Read image with dimensions: {}x{}", dynamic_image.width(), dynamic_image.height()); + crate::image_loader::load_base_image_from_bytes(&file_bytes, path, false, 2.5) + .map_err(|e| format!("Failed to load image {}: {}", path, e)) + .unwrap(); + + println!( + "Read image with dimensions: {}x{}", + dynamic_image.width(), + dynamic_image.height() + ); - let exif = get_exif_data(&file_bytes).unwrap(); - println!("Read image with exif:"); - let gains = get_gains(&exif).unwrap_or(1.0); - println!("Read image with gains: {}x{}", gains, dynamic_image.width()); - let exposure = get_exposures(&exif).unwrap_or(1.0); - println!("Read image with exposures: {}x{}", exposure, dynamic_image.width()); + let (gains, exposure) = match get_exif_data(&file_bytes) { + Ok(exif) => { + let gains = get_gains(&exif).unwrap_or(1.0); + println!("Read image with gains: {}x{}", gains, dynamic_image.width()); + let exposure = get_exposures(&exif).unwrap_or(1.0); + println!( + "Read image with exposures: {}x{}", + exposure, + dynamic_image.width() + ); + (gains, exposure) + } + Err(_) => { + (1.0, 1.0) // TODO + } + }; HDRInput::with_image(&dynamic_image, Duration::from_secs_f32(exposure), gains).unwrap() }) @@ -2416,13 +2433,12 @@ async fn merge_hdr( let mut buf = Cursor::new(Vec::new()); if let Err(e) = stretched.write_to(&mut buf, ImageFormat::Png) { - return Err(format!("Failed to encode panorama preview: {}", e)); + 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.to_rgb8()); diff --git a/src/App.tsx b/src/App.tsx index 82edcfb4d..0c34f4eaf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -114,6 +114,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 @@ -2563,6 +2564,26 @@ 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 handleSaveCollage = async (base64Data: string, firstPath: string): Promise => { try { const savedPath: string = await invoke(Invokes.SaveCollage, { @@ -4020,6 +4041,24 @@ 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} + /> setIsCreateFolderModalOpen(false)} diff --git a/src/components/modals/HdrModal.tsx b/src/components/modals/HdrModal.tsx new file mode 100644 index 000000000..80d5d9876 --- /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 ( + <> + +

Panorama Failed

+

+ {String(error)} +

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

Panorama Saved!

+ + )} +
+ Stitched Panorama +
+ + ); + } + + return ( + <> +
+ +
+

Stitching Panorama

+

{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 ccd44d714..e4be1cdc0 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -62,6 +62,7 @@ export enum Invokes { SaveMetadataAndUpdateThumbnail = 'save_metadata_and_update_thumbnail', SaveCollage = 'save_collage', SavePanorama = 'save_panorama', + SaveHdr = 'save_hdr', SavePresets = 'save_presets', SaveSettings = 'save_settings', SetColorLabelForPaths = 'set_color_label_for_paths', From 1e9e7cf58ee335e4e56008981b63b3ac0c292600 Mon Sep 17 00:00:00 2001 From: SimonIT Date: Wed, 17 Dec 2025 22:09:43 +0100 Subject: [PATCH 03/10] fix: Png doesn't want Rgb32F --- src-tauri/src/main.rs | 3 ++- src-tauri/src/panorama_stitching.rs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index aa5aa8205..b7a4a2a4e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2432,7 +2432,8 @@ async fn merge_hdr( let mut buf = Cursor::new(Vec::new()); - if let Err(e) = stretched.write_to(&mut buf, ImageFormat::Png) { + if let Err(e) = stretched.to_rgb8().write_to(&mut buf, ImageFormat::Png) { + // to_rgb8() is not nice but hdr preview as png return Err(format!("Failed to encode hdr preview: {}", e)); } diff --git a/src-tauri/src/panorama_stitching.rs b/src-tauri/src/panorama_stitching.rs index 702739ea2..72ad39c44 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()); } From 35f10261ea1f3016dea0f120d03bf2c16e6e3f25 Mon Sep 17 00:00:00 2001 From: SimonIT Date: Tue, 27 Jan 2026 17:11:11 +0100 Subject: [PATCH 04/10] fix: Use ISO and exposure from exif --- src-tauri/Cargo.lock | 21 ++--------- src-tauri/src/main.rs | 82 ++++++++++++++++++++++++++++++++----------- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ff2f1689a..8c9552236 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -30,7 +30,7 @@ dependencies = [ "memmap2", "mimalloc", "nalgebra 0.34.1", - "ndarray 0.15.6", + "ndarray", "num_cpus", "once_cell", "ort", @@ -2976,7 +2976,7 @@ checksum = "0f401958e284ad5c7a124aa8cbd83e8072e91f7c04ca48c77e1f55d9b660206d" dependencies = [ "image", "kamadak-exif", - "ndarray 0.16.1", + "ndarray", "rayon", "thiserror 2.0.17", ] @@ -4044,21 +4044,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "ndarray" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", -] - [[package]] name = "ndarray" version = "0.16.1" @@ -4653,7 +4638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721" dependencies = [ "libloading 0.8.9", - "ndarray 0.15.6", + "ndarray", "ort-sys", "smallvec 2.0.0-alpha.10", "tracing", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7f32d8dc7..c0033d3fb 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -30,14 +30,15 @@ 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}; @@ -54,7 +55,7 @@ use image_hdr::stretch::apply_histogram_stretch; use image_hdr::{Error, hdr_merge_images}; 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}; @@ -70,18 +71,16 @@ 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::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}; @@ -2888,17 +2887,31 @@ async fn merge_hdr( let (gains, exposure) = match get_exif_data(&file_bytes) { Ok(exif) => { let gains = get_gains(&exif).unwrap_or(1.0); - println!("Read image with gains: {}x{}", gains, dynamic_image.width()); + println!("Read image with gains: {}", gains); let exposure = get_exposures(&exif).unwrap_or(1.0); println!( - "Read image with exposures: {}x{}", - exposure, - dynamic_image.width() + "Read image with exposure: {}", + exposure ); (gains, exposure) } Err(_) => { - (1.0, 1.0) // TODO + let exif = exif_processing::read_exif_data(&path, &file_bytes); + + let gains = match exif.get("PhotographicSensitivity") { + None => 1.0, + Some(gains) => f32::from_str(gains).unwrap_or_else(|_| 1.0), + }; + println!("Read image with gains: {}", gains); + let exposure = match exif.get("ExposureTime") { + None => 1.0, + Some(exposure) => parse_exposure_time(exposure).unwrap_or_else(|_| 1.0), + }; + println!( + "Read image with exposure: {}", + exposure + ); + (gains, exposure) } }; @@ -2939,6 +2952,38 @@ async fn merge_hdr( Ok(()) } +fn parse_exposure_time(input: &str) -> Result { + // 1. Remove units like "s", "sec", or whitespace + let cleaned: String = input + .chars() + .filter(|c| c.is_numeric() || *c == '/' || *c == '.') + .collect(); + + if cleaned.is_empty() { + return Err("No numeric data found".to_string()); + } + + // 2. Handle fractional format + if cleaned.contains('/') { + let parts: Vec<&str> = cleaned.split('/').collect(); + if parts.len() == 2 { + let num = parts[0].parse::().map_err(|_| "Invalid numerator")?; + let den = parts[1].parse::().map_err(|_| "Invalid denominator")?; + + if den == 0.0 { + return Err("Division by zero".to_string()); + } + Ok(num / den) + } else { + Err("Invalid fraction format".to_string()) + } + } else { + // 3. Handle decimal format + cleaned.parse::() + .map_err(|_| format!("Could not parse '{}' as f32", cleaned)) + } +} + #[tauri::command] async fn apply_denoising( path: String, @@ -3241,10 +3286,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()) } From 03fd3fb40aa811cd8e8cfe0a8535e3117588ccf3 Mon Sep 17 00:00:00 2001 From: SimonIT Date: Wed, 28 Jan 2026 14:43:39 +0100 Subject: [PATCH 05/10] Implement saving --- src-tauri/src/main.rs | 49 +++++++++++++++++++++++++++--- src/components/modals/HdrModal.tsx | 10 +++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c0033d3fb..1d050dbc3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -130,7 +130,7 @@ pub struct AppState { ai_state: Mutex>, ai_init_lock: TokioMutex<()>, export_task_handle: Mutex>>, - hdr_result: Arc>>, + hdr_result: Arc>>, panorama_result: Arc>>, denoise_result: Arc>>, indexing_task_handle: Mutex>>, @@ -2932,7 +2932,6 @@ async fn merge_hdr( let mut buf = Cursor::new(Vec::new()); if let Err(e) = stretched.to_rgb8().write_to(&mut buf, ImageFormat::Png) { - // to_rgb8() is not nice but hdr preview as png return Err(format!("Failed to encode hdr preview: {}", e)); } @@ -2941,7 +2940,7 @@ async fn merge_hdr( let _ = app_handle.emit("hdr-progress", "Creating preview..."); - *hdr_result_handle.lock().unwrap() = Some(stretched.to_rgb8()); + *hdr_result_handle.lock().unwrap() = Some(stretched); let _ = app_handle.emit( "hdr-complete", @@ -2984,6 +2983,47 @@ fn parse_exposure_time(input: &str) -> Result { } } +#[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, @@ -3464,8 +3504,9 @@ fn main() { get_log_file_path, save_collage, stitch_panorama, - merge_hdr, save_panorama, + merge_hdr, + save_hdr, apply_denoising, save_denoised_image, load_and_parse_lut, diff --git a/src/components/modals/HdrModal.tsx b/src/components/modals/HdrModal.tsx index 80d5d9876..be40b2094 100644 --- a/src/components/modals/HdrModal.tsx +++ b/src/components/modals/HdrModal.tsx @@ -82,7 +82,7 @@ export default function HdrModal({ return ( <> -

Panorama Failed

+

HDR Failed

{String(error)}

@@ -96,11 +96,11 @@ export default function HdrModal({ {savedPath && ( <> -

Panorama Saved!

+

HDR Saved!

)}
- Stitched Panorama + Merged HDR
); @@ -111,7 +111,7 @@ export default function HdrModal({
-

Stitching Panorama

+

Merging HDR

{progressMessage}

); @@ -149,7 +149,7 @@ export default function HdrModal({ ); From 76cae54a54ea9b4e502e2e908f367e665dbf6cfe Mon Sep 17 00:00:00 2001 From: SimonIT Date: Mon, 2 Feb 2026 17:21:42 +0100 Subject: [PATCH 06/10] Fix raw loading for hdr --- src-tauri/src/main.rs | 49 +++++++++++++++++++------------------------ 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f01023d2d..85b045f22 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -42,17 +42,15 @@ use std::thread; use std::time::Duration; use base64::{Engine as _, engine::general_purpose}; -use chrono::{DateTime, Utc}; -use exif::{Exif, In, Tag}; use image::codecs::jpeg::JpegEncoder; use image::{ DynamicImage, GenericImageView, GrayImage, ImageBuffer, ImageFormat, Luma, Rgb, RgbImage, Rgba, RgbaImage, imageops, }; use image_hdr::exif::{get_exif_data, get_exposures, get_gains}; +use image_hdr::hdr_merge_images; use image_hdr::input::HDRInput; use image_hdr::stretch::apply_histogram_stretch; -use image_hdr::{Error, hdr_merge_images}; use imageproc::drawing::draw_line_segment_mut; use imageproc::edges::canny; use imageproc::hough::{LineDetectionOptions, detect_lines}; @@ -2905,10 +2903,9 @@ async fn merge_hdr( let file_bytes = fs::read(path) .map_err(|e| format!("Failed to read image {}: {}", path, e)) .unwrap(); - let dynamic_image = - crate::image_loader::load_base_image_from_bytes(&file_bytes, path, false, 2.5) - .map_err(|e| format!("Failed to load image {}: {}", path, e)) - .unwrap(); + 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)) + .unwrap(); println!( "Read image with dimensions: {}x{}", @@ -2921,10 +2918,7 @@ async fn merge_hdr( let gains = get_gains(&exif).unwrap_or(1.0); println!("Read image with gains: {}", gains); let exposure = get_exposures(&exif).unwrap_or(1.0); - println!( - "Read image with exposure: {}", - exposure - ); + println!("Read image with exposure: {}", exposure); (gains, exposure) } Err(_) => { @@ -2939,10 +2933,7 @@ async fn merge_hdr( None => 1.0, Some(exposure) => parse_exposure_time(exposure).unwrap_or_else(|_| 1.0), }; - println!( - "Read image with exposure: {}", - exposure - ); + println!("Read image with exposure: {}", exposure); (gains, exposure) } }; @@ -3010,7 +3001,8 @@ fn parse_exposure_time(input: &str) -> Result { } } else { // 3. Handle decimal format - cleaned.parse::() + cleaned + .parse::() .map_err(|_| format!("Could not parse '{}' as f32", cleaned)) } } @@ -3020,15 +3012,9 @@ 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 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 @@ -3039,12 +3025,19 @@ async fn save_hdr( .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())) + 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())) + ( + format!("{}_Hdr.png", stem), + DynamicImage::ImageRgb8(hdr_image.to_rgb8()), + ) }; let output_path = parent_dir.join(output_filename); From 9bca6cc0e08963ad625abdbd8fe9b5816018920b Mon Sep 17 00:00:00 2001 From: SimonIT Date: Tue, 10 Feb 2026 21:03:15 +0100 Subject: [PATCH 07/10] Better logging and error handling --- src-tauri/src/main.rs | 76 +++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d3457a718..e82f13e8d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -48,13 +48,13 @@ use image::{ DynamicImage, GenericImageView, GrayImage, ImageBuffer, ImageFormat, Luma, Rgb, RgbImage, Rgba, RgbaImage, imageops, }; -use image_hdr::exif::{get_exif_data, get_exposures, get_gains}; 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::{LineDetectionOptions, detect_lines}; +use log::debug; use rayon::prelude::*; use reqwest; use serde::{Deserialize, Serialize}; @@ -2900,59 +2900,59 @@ async fn merge_hdr( .to_string_lossy() ), ); - println!(" - Processing '{}'", path); - let file_bytes = fs::read(path) - .map_err(|e| format!("Failed to read image {}: {}", path, e)) - .unwrap(); + 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)) - .unwrap(); + .map_err(|e| format!("Failed to load image {}: {}", path, e))?; - println!( - "Read image with dimensions: {}x{}", - dynamic_image.width(), - dynamic_image.height() - ); + let exif = exif_processing::read_exif_data(&path, &file_bytes); - let (gains, exposure) = match get_exif_data(&file_bytes) { - Ok(exif) => { - let gains = get_gains(&exif).unwrap_or(1.0); - println!("Read image with gains: {}", gains); - let exposure = get_exposures(&exif).unwrap_or(1.0); - println!("Read image with exposure: {}", exposure); - (gains, exposure) + let gains = match exif.get("PhotographicSensitivity") { + None => { + return Err(format!( + "Image {} is missing 'PhotographicSensitivity' in EXIF data", + path + )); } - Err(_) => { - let exif = exif_processing::read_exif_data(&path, &file_bytes); - - let gains = match exif.get("PhotographicSensitivity") { - None => 1.0, - Some(gains) => f32::from_str(gains).unwrap_or_else(|_| 1.0), - }; - println!("Read image with gains: {}", gains); - let exposure = match exif.get("ExposureTime") { - None => 1.0, - Some(exposure) => parse_exposure_time(exposure).unwrap_or_else(|_| 1.0), - }; - println!("Read image with exposure: {}", exposure); - (gains, exposure) + Some(gains) => f32::from_str(gains).map_err(|_| { + format!( + "Could not parse PhotographicSensitivity '{}' as f32 from image {}", + gains, path + ) + })?, + }; + debug!("Read image {} with gains: {}", path, gains); + let exposure = match exif.get("ExposureTime") { + None => { + return Err(format!( + "Image {} is missing 'ExposureTime' in EXIF data", + path + )); } + Some(exposure) => parse_exposure_time(exposure).map_err(|e| { + format!( + "Could not parse ExposureTime '{}' as f32 from image {}: {}", + exposure, path, e + ) + })?, }; + debug!("Read image {} with exposure: {}", path, exposure); - HDRInput::with_image(&dynamic_image, Duration::from_secs_f32(exposure), gains).unwrap() + 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(); + .collect::, String>>()?; - println!("Starting HDR merge of {} images", images.len()); + debug!("Starting HDR merge of {} images", images.len()); let hdr_merged = hdr_merge_images(&mut images.into()).map_err(|e| e.to_string())?; - println!("HDR merge completed"); + debug!("HDR merge completed"); let stretched = apply_histogram_stretch(&hdr_merged).map_err(|e| e.to_string())?; - println!("Histogram stretch applied"); + debug!("Histogram stretch applied"); let mut buf = Cursor::new(Vec::new()); From 78b184155ec7f14f2295d02242b4bc9b427a37c0 Mon Sep 17 00:00:00 2001 From: SimonIT Date: Thu, 12 Feb 2026 16:35:45 +0100 Subject: [PATCH 08/10] Replace log::debug with log::info! --- src-tauri/src/main.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e82f13e8d..ed9a89702 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -54,7 +54,6 @@ use image_hdr::stretch::apply_histogram_stretch; use imageproc::drawing::draw_line_segment_mut; use imageproc::edges::canny; use imageproc::hough::{LineDetectionOptions, detect_lines}; -use log::debug; use rayon::prelude::*; use reqwest; use serde::{Deserialize, Serialize}; @@ -2922,7 +2921,7 @@ async fn merge_hdr( ) })?, }; - debug!("Read image {} with gains: {}", path, gains); + log::info!("Read image {} with gains: {}", path, gains); let exposure = match exif.get("ExposureTime") { None => { return Err(format!( @@ -2937,22 +2936,22 @@ async fn merge_hdr( ) })?, }; - debug!("Read image {} with exposure: {}", path, 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>>()?; - debug!("Starting HDR merge of {} images", images.len()); + log::info!("Starting HDR merge of {} images", images.len()); let hdr_merged = hdr_merge_images(&mut images.into()).map_err(|e| e.to_string())?; - debug!("HDR merge completed"); + log::info!("HDR merge completed"); let stretched = apply_histogram_stretch(&hdr_merged).map_err(|e| e.to_string())?; - debug!("Histogram stretch applied"); + log::info!("Histogram stretch applied"); let mut buf = Cursor::new(Vec::new()); From 4f659bf94c43ec0d0e06aecb826b7d95e8f7b6ff Mon Sep 17 00:00:00 2001 From: SimonIT Date: Fri, 13 Feb 2026 11:09:20 +0100 Subject: [PATCH 09/10] Read the gain and exposure values directly from the file without formatting --- src-tauri/src/exif_processing.rs | 116 ++++++++++++++++++++++++------- src-tauri/src/file_management.rs | 5 +- src-tauri/src/main.rs | 56 ++------------- 3 files changed, 98 insertions(+), 79 deletions(-) diff --git a/src-tauri/src/exif_processing.rs b/src-tauri/src/exif_processing.rs index 3cb37466b..874a9c216 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}; 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,93 @@ 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) { + let num = exposure.value.get_uint(0)?; + let denom = exposure.value.get_uint(0)?; + return if denom == 0 { + None + } else { + Some(num as f32 / denom as f32) + }; + } else if let Some(shutter_speed) = + exif.get_field(exif::Tag::ShutterSpeedValue, In::PRIMARY) + { + let num = shutter_speed.value.get_uint(0)?; + let denom = shutter_speed.value.get_uint(0)?; + return if denom == 0 { + None + } else { + Some(num as f32 / 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 +132,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 +207,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 ed9a89702..3f83bb772 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -69,6 +69,7 @@ use crate::ai_processing::{ generate_image_embeddings, get_or_init_ai_models, run_sam_decoder, run_sky_seg_model, run_u2netp_model, }; +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::{ @@ -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); @@ -2905,36 +2906,24 @@ async fn merge_hdr( 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 exif = exif_processing::read_exif_data(&path, &file_bytes); - - let gains = match exif.get("PhotographicSensitivity") { + let gains = match read_iso(&path, &file_bytes) { None => { return Err(format!( "Image {} is missing 'PhotographicSensitivity' in EXIF data", path )); } - Some(gains) => f32::from_str(gains).map_err(|_| { - format!( - "Could not parse PhotographicSensitivity '{}' as f32 from image {}", - gains, path - ) - })?, + Some(gains) => gains as f32, }; log::info!("Read image {} with gains: {}", path, gains); - let exposure = match exif.get("ExposureTime") { + let exposure = match read_exposure_time_secs(&path, &file_bytes) { None => { return Err(format!( "Image {} is missing 'ExposureTime' in EXIF data", path )); } - Some(exposure) => parse_exposure_time(exposure).map_err(|e| { - format!( - "Could not parse ExposureTime '{}' as f32 from image {}: {}", - exposure, path, e - ) - })?, + Some(exposure) => exposure, }; log::info!("Read image {} with exposure: {}", path, exposure); @@ -2975,39 +2964,6 @@ async fn merge_hdr( Ok(()) } -fn parse_exposure_time(input: &str) -> Result { - // 1. Remove units like "s", "sec", or whitespace - let cleaned: String = input - .chars() - .filter(|c| c.is_numeric() || *c == '/' || *c == '.') - .collect(); - - if cleaned.is_empty() { - return Err("No numeric data found".to_string()); - } - - // 2. Handle fractional format - if cleaned.contains('/') { - let parts: Vec<&str> = cleaned.split('/').collect(); - if parts.len() == 2 { - let num = parts[0].parse::().map_err(|_| "Invalid numerator")?; - let den = parts[1].parse::().map_err(|_| "Invalid denominator")?; - - if den == 0.0 { - return Err("Division by zero".to_string()); - } - Ok(num / den) - } else { - Err("Invalid fraction format".to_string()) - } - } else { - // 3. Handle decimal format - cleaned - .parse::() - .map_err(|_| format!("Could not parse '{}' as f32", cleaned)) - } -} - #[tauri::command] async fn save_hdr( first_path_str: String, From b63fccc954d75b61077d29a8de77fda2a9256df9 Mon Sep 17 00:00:00 2001 From: SimonIT Date: Fri, 13 Feb 2026 11:29:39 +0100 Subject: [PATCH 10/10] Fix exposure reading with exif --- src-tauri/src/exif_processing.rs | 42 ++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/exif_processing.rs b/src-tauri/src/exif_processing.rs index 874a9c216..77235884f 100644 --- a/src-tauri/src/exif_processing.rs +++ b/src-tauri/src/exif_processing.rs @@ -5,7 +5,7 @@ use std::path::Path; use crate::formats::is_raw_file; use chrono::{DateTime, Utc}; -use exif::{Exif, Field, In}; +use exif::{Exif, Field, In, Value}; use little_exif::exif_tag::ExifTag; use little_exif::filetype::FileExtension; use little_exif::metadata::Metadata; @@ -70,23 +70,35 @@ pub fn read_exposure_time_secs(path: &str, file_bytes: &[u8]) -> Option { if let Some(exif) = read_exif(file_bytes) { if let Some(exposure) = exif.get_field(exif::Tag::ExposureTime, In::PRIMARY) { - let num = exposure.value.get_uint(0)?; - let denom = exposure.value.get_uint(0)?; - return if denom == 0 { - None - } else { - Some(num as f32 / denom as f32) - }; + 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) { - let num = shutter_speed.value.get_uint(0)?; - let denom = shutter_speed.value.get_uint(0)?; - return if denom == 0 { - None - } else { - Some(num as f32 / denom as f32) - }; + 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