diff --git a/src-tauri/src/android_integration.rs b/src-tauri/src/android_integration.rs index 9a027660d9..f67da917e7 100644 --- a/src-tauri/src/android_integration.rs +++ b/src-tauri/src/android_integration.rs @@ -39,17 +39,12 @@ pub fn initialize_android(window: &tauri::WebviewWindow) { .with_env(|env_22| { let verifier_context = unsafe { VerifierJObject::from_raw(env_22, raw_context) }; - rustls_platform_verifier::android::init_with_env( - env_22, - verifier_context, - ) + rustls_platform_verifier::android::init_with_env(env_22, verifier_context) }) .into_outcome() { jni22::Outcome::Ok(()) => { - log::info!( - "Successfully initialized rustls-platform-verifier on Android." - ); + log::info!("Successfully initialized rustls-platform-verifier on Android."); } jni22::Outcome::Err(error) => { log::error!( diff --git a/src-tauri/src/cache_utils.rs b/src-tauri/src/cache_utils.rs index 63084ee53d..201e75e346 100644 --- a/src-tauri/src/cache_utils.rs +++ b/src-tauri/src/cache_utils.rs @@ -34,6 +34,11 @@ pub fn calculate_geometry_hash(adjustments: &serde_json::Value) -> u64 { adjustments["orientationSteps"].as_u64().hash(&mut hasher); + // Negative conversion changes the decoded base, so it must key the base cache. + if let Some(nc) = adjustments.get("negativeConversion") { + nc.to_string().hash(&mut hasher); + } + for key in GEOMETRY_KEYS { if let Some(val) = adjustments.get(key) { key.hash(&mut hasher); diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 43d8fac765..999c9d07ad 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -147,12 +147,22 @@ pub struct ImageFile { path: String, modified: u64, is_edited: bool, + is_negative: bool, rating: u8, tags: Option>, exif: Option>, is_virtual_copy: bool, } +/// True when the sidecar has an enabled in-library negative conversion. +pub(crate) fn adjustments_is_negative(adjustments: &serde_json::Value) -> bool { + adjustments + .get("negativeConversion") + .and_then(|nc| nc.get("enabled")) + .and_then(|e| e.as_bool()) + .unwrap_or(false) +} + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct ImportSettings { @@ -351,7 +361,7 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result Result(); + + // Evict any pre-change cached base so the new one is decoded. + if let Ok(mut cache) = state.thumbnail_geometry_cache.lock() { + for p in paths { + cache.remove(p); + } + } + + let settings = load_settings(app_handle.clone()).unwrap_or_default(); + add_to_thumbnail_queue(&state, paths.len(), app_handle); + + let thumb_cache_dir = match resolve_thumbnail_cache_dir(app_handle) { + Ok(dir) => dir, + Err(e) => { + log::warn!("Unable to initialize thumbnail cache directory: {}", e); + for path in paths { + emit_thumbnail_cache_setup_error(app_handle, path, &e); + } + for _ in 0..paths.len() { + increment_thumbnail_progress(&state, app_handle); + } + return; + } + }; + + let gpu_context = crate::gpu_processing::get_or_init_gpu_context(&state, app_handle).ok(); + + paths.par_iter().for_each(|path_str| { + let result = generate_single_thumbnail_and_cache( + path_str, + &thumb_cache_dir, + gpu_context.as_ref(), + None, + true, + app_handle, + &settings, + ); + + if let Some((thumbnail_data, rating, is_edited)) = result { + let _ = app_handle.emit( + "thumbnail-generated", + serde_json::json!({ "path": path_str, "data": thumbnail_data, "rating": rating, "is_edited": is_edited }), + ); + } + + increment_thumbnail_progress(&state, app_handle); + }); +} + pub fn resolve_lens_params_in_adjustments( adjustments: &mut Value, exif_data: &Option>, @@ -2072,7 +2141,20 @@ pub fn save_metadata_and_update_thumbnail( ); } + // `negativeConversion` is owned by set_negative_conversion; a normal save must + // preserve the sidecar's own value rather than the frontend's. + let preserved_negative = metadata.adjustments.get("negativeConversion").cloned(); metadata.adjustments = final_adjustments; + if let Some(obj) = metadata.adjustments.as_object_mut() { + match preserved_negative { + Some(nc) => { + obj.insert("negativeConversion".to_string(), nc); + } + None => { + obj.remove("negativeConversion"); + } + } + } let json_string = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; std::fs::write(&sidecar_path, json_string).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/image_loader.rs b/src-tauri/src/image_loader.rs index 61cb8836ab..71365dc5d6 100644 --- a/src-tauri/src/image_loader.rs +++ b/src-tauri/src/image_loader.rs @@ -61,12 +61,34 @@ pub fn load_and_composite( composite_patches_on_image(&base_image, adjustments) } +/// Decode a base image and apply a stored negative conversion if the sidecar flags one. pub fn load_base_image_from_bytes( bytes: &[u8], path_for_ext_check: &str, use_fast_raw_dev: bool, settings: &AppSettings, cancel_token: Option<(Arc, usize)>, +) -> Result { + let image = load_base_image_raw( + bytes, + path_for_ext_check, + use_fast_raw_dev, + settings, + cancel_token, + )?; + Ok(crate::negative_conversion::maybe_apply_negative( + image, + path_for_ext_check, + )) +} + +/// The raw decode, without applying a stored negative conversion. +pub fn load_base_image_raw( + bytes: &[u8], + path_for_ext_check: &str, + use_fast_raw_dev: bool, + settings: &AppSettings, + cancel_token: Option<(Arc, usize)>, ) -> Result { let highlight_compression = settings.raw_highlight_compression.unwrap_or(2.5); let linear_mode = settings.linear_raw_mode.clone(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 20c3af21ec..bbfa1bfe5a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2316,8 +2316,7 @@ pub fn run() { lens_correction::get_lensfun_lenses_for_maker, lens_correction::autodetect_lens, lens_correction::get_lens_distortion_params, - negative_conversion::preview_negative_conversion, - negative_conversion::convert_negatives, + negative_conversion::set_negative_conversion, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/lut_processing.rs b/src-tauri/src/lut_processing.rs index a6bcea6d85..54f4128a8c 100644 --- a/src-tauri/src/lut_processing.rs +++ b/src-tauri/src/lut_processing.rs @@ -330,8 +330,7 @@ pub fn parse_lut_file(path_str: &str) -> anyhow::Result { .and_then(|s| s.to_str()) .unwrap_or("cube") .to_lowercase(); - let uri_bytes = - read_android_content_uri(path_str).map_err(|e| anyhow!("{}", e))?; + let uri_bytes = read_android_content_uri(path_str).map_err(|e| anyhow!("{}", e))?; (ext, Some(uri_bytes)) } #[cfg(not(target_os = "android"))] diff --git a/src-tauri/src/negative_conversion.rs b/src-tauri/src/negative_conversion.rs index b31517d37f..0260586090 100644 --- a/src-tauri/src/negative_conversion.rs +++ b/src-tauri/src/negative_conversion.rs @@ -1,15 +1,10 @@ use crate::file_management::{parse_virtual_path, read_file_mapped}; -use crate::image_loader::load_base_image_from_bytes; -use base64::{Engine as _, engine::general_purpose}; -use image::codecs::jpeg::JpegEncoder; +use crate::image_loader::load_base_image_raw; use image::{DynamicImage, Rgb32FImage}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; -use std::collections::hash_map::DefaultHasher; use std::fs; -use std::hash::{Hash, Hasher}; -use std::io::Cursor; use std::path::Path; use tauri::AppHandle; @@ -179,169 +174,181 @@ fn run_pipeline( DynamicImage::ImageRgb32F(out_img) } -#[tauri::command] -pub async fn preview_negative_conversion( - path: String, - params: NegativeConversionParams, - state: tauri::State<'_, AppState>, - app_handle: AppHandle, -) -> Result { - let (source_path, _) = parse_virtual_path(&path); - let source_path_str = source_path.to_string_lossy().to_string(); - - let mut hasher = DefaultHasher::new(); - source_path_str.hash(&mut hasher); - "negative_preview_base".hash(&mut hasher); - let cache_key = hasher.finish(); - - let base_image_for_processing = { - let mut cache = state.geometry_cache.lock().unwrap(); - - if let Some(cached_img) = cache.get(&cache_key) { - cached_img.clone() - } else { - let image_to_downscale = { - let original_lock = state.original_image.lock().unwrap(); - if let Some(loaded) = original_lock.as_ref() { - if loaded.path == source_path_str { - loaded.image.clone().as_ref().clone() - } else { - drop(original_lock); - let settings = load_settings(app_handle.clone()).unwrap_or_default(); - - match read_file_mapped(Path::new(&source_path_str)) { - Ok(mmap) => load_base_image_from_bytes( - &mmap, - &source_path_str, - false, - &settings, - None, - ) - .map_err(|e| e.to_string())?, - Err(_e) => { - let bytes = fs::read(&source_path_str) - .map_err(|io_err| io_err.to_string())?; - load_base_image_from_bytes( - &bytes, - &source_path_str, - false, - &settings, - None, - ) - .map_err(|e| e.to_string())? - } - } - } - } else { - drop(original_lock); - let settings = load_settings(app_handle.clone()).unwrap_or_default(); - - match read_file_mapped(Path::new(&source_path_str)) { - Ok(mmap) => load_base_image_from_bytes( - &mmap, - &source_path_str, - false, - &settings, - None, - ) - .map_err(|e| e.to_string())?, - Err(_e) => { - let bytes = - fs::read(&source_path_str).map_err(|io_err| io_err.to_string())?; - load_base_image_from_bytes( - &bytes, - &source_path_str, - false, - &settings, - None, - ) - .map_err(|e| e.to_string())? - } - } - } - }; - - let downscaled = downscale_f32_image(&image_to_downscale, 1080, 1080); - - cache.insert(cache_key, downscaled.clone()); - downscaled - } +fn stored_negative( + adjustments: &serde_json::Value, +) -> Option<(NegativeConversionParams, [ChannelBounds; 3])> { + let nc = adjustments.get("negativeConversion")?; + if !nc.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false) { + return None; + } + let get = |k: &str, d: f32| { + nc.get(k) + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + .unwrap_or(d) }; + let params = NegativeConversionParams { + red_weight: get("redWeight", 1.0), + green_weight: get("greenWeight", 1.0), + blue_weight: get("blueWeight", 1.0), + exposure: get("exposure", 0.0), + contrast: get("contrast", 1.0), + }; + let arr = nc.get("bounds")?.as_array()?; + if arr.len() != 3 { + return None; + } + let mut bounds = [ChannelBounds { min: 0.0, max: 1.0 }; 3]; + for (i, b) in bounds.iter_mut().enumerate() { + let pair = arr[i].as_array()?; + b.min = pair.first()?.as_f64()? as f32; + b.max = pair.get(1)?.as_f64()? as f32; + } + Some((params, bounds)) +} - let processed = run_pipeline(&base_image_for_processing, ¶ms, None); - - let mut buf = Cursor::new(Vec::new()); - processed - .to_rgb8() - .write_with_encoder(JpegEncoder::new_with_quality(&mut buf, 80)) - .map_err(|e| e.to_string())?; +/// Invert a flagged negative to a positive on load; a no-op for normal images. +pub fn maybe_apply_negative(image: DynamicImage, real_path: &str) -> DynamicImage { + let sidecar = crate::exif_processing::get_primary_sidecar_path(Path::new(real_path)); + if !sidecar.exists() { + return image; + } + let meta = crate::exif_processing::load_sidecar(&sidecar); + match stored_negative(&meta.adjustments) { + Some((params, bounds)) => run_pipeline(&image, ¶ms, Some(bounds)), + None => image, + } +} - let base64_str = general_purpose::STANDARD.encode(buf.get_ref()); - Ok(format!("data:image/jpeg;base64,{}", base64_str)) +fn analyze_bounds_for(image: &DynamicImage) -> [ChannelBounds; 3] { + let ref_img = downscale_f32_image(image, 1080, 1080); + let ref_rgb = ref_img.to_rgb32f(); + let (w, h) = ref_rgb.dimensions(); + let log_pixels: Vec = ref_rgb + .as_raw() + .par_iter() + .map(|&v| -v.clamp(1e-6, 1.0).log10()) + .collect(); + analyze_bounds(&log_pixels, w as usize, h as usize) } +/// Turn the in-library negative conversion on (`enabled = true`, stores auto-bounds in +/// the sidecar) or off (`enabled = false`, removes it) for the given raws. #[tauri::command] -pub async fn convert_negatives( +pub async fn set_negative_conversion( paths: Vec, - params: NegativeConversionParams, + enabled: bool, + state: tauri::State<'_, AppState>, app_handle: AppHandle, ) -> Result, String> { - tokio::task::spawn_blocking(move || { + let handle = app_handle.clone(); + let results = tokio::task::spawn_blocking(move || -> Result, String> { let mut results = Vec::new(); for (i, path_str) in paths.iter().enumerate() { - let _ = app_handle.emit( + let _ = handle.emit( "negative-batch-progress", - serde_json::json!({ - "current": i + 1, - "total": paths.len(), - "path": path_str - }), + serde_json::json!({ "current": i + 1, "total": paths.len(), "path": path_str }), ); let (source_path, _) = parse_virtual_path(path_str); - let real_path = source_path.to_string_lossy().to_string(); - - let settings = load_settings(app_handle.clone()).unwrap_or_default(); + let sidecar = crate::exif_processing::get_primary_sidecar_path(&source_path); + let mut meta = crate::exif_processing::load_sidecar(&sidecar); + if !meta.adjustments.is_object() { + meta.adjustments = serde_json::json!({}); + } - let img = match read_file_mapped(Path::new(&real_path)) { - Ok(mmap) => load_base_image_from_bytes(&mmap, &real_path, false, &settings, None), - Err(_) => { - let bytes = fs::read(&real_path).unwrap_or_default(); - load_base_image_from_bytes(&bytes, &real_path, false, &settings, None) + if enabled { + let real_path = source_path.to_string_lossy().to_string(); + let settings = load_settings(handle.clone()).unwrap_or_default(); + let img = match read_file_mapped(Path::new(&real_path)) { + Ok(mmap) => load_base_image_raw(&mmap, &real_path, false, &settings, None), + Err(_) => { + let bytes = fs::read(&real_path).map_err(|e| e.to_string())?; + load_base_image_raw(&bytes, &real_path, false, &settings, None) + } } + .map_err(|e| e.to_string())?; + + let bounds = analyze_bounds_for(&img); + meta.adjustments["negativeConversion"] = serde_json::json!({ + "enabled": true, + "bounds": [ + [bounds[0].min, bounds[0].max], + [bounds[1].min, bounds[1].max], + [bounds[2].min, bounds[2].max], + ], + }); + } else if let Some(obj) = meta.adjustments.as_object_mut() { + obj.remove("negativeConversion"); } - .map_err(|e| e.to_string())?; - - let bounds_ref = downscale_f32_image(&img, 1080, 1080); - let ref_rgb = bounds_ref.to_rgb32f(); - let (ref_w, ref_h) = ref_rgb.dimensions(); - let log_pixels: Vec = ref_rgb - .as_raw() - .par_iter() - .map(|&v| -v.clamp(1e-6, 1.0).log10()) - .collect(); - let bounds = analyze_bounds(&log_pixels, ref_w as usize, ref_h as usize); - - let processed = run_pipeline(&img, ¶ms, Some(bounds)); - - let p = Path::new(&real_path); - let parent = p.parent().unwrap_or(Path::new("")); - let stem = p.file_stem().unwrap_or_default().to_string_lossy(); - let filename = format!("{}_Positive.tiff", stem); - let out_path = parent.join(&filename); - - processed - .to_rgb16() - .save(&out_path) - .map_err(|e| format!("Failed to save {}: {}", filename, e))?; - - let _ = crate::exif_processing::write_rrexif_sidecar(&real_path, &out_path); - results.push(out_path.to_string_lossy().to_string()); + + let json = serde_json::to_string_pretty(&meta).map_err(|e| e.to_string())?; + fs::write(&sidecar, json).map_err(|e| e.to_string())?; + results.push(path_str.clone()); } Ok(results) }) .await - .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())??; + + // The base image changed; drop cached decodes/previews so the new render is used. + if let Ok(mut c) = state.decoded_image_cache.lock() { + c.clear(); + } + if let Ok(mut c) = state.geometry_cache.lock() { + c.clear(); + } + if let Ok(mut c) = state.cached_preview.lock() { + *c = None; + } + if let Ok(mut c) = state.full_warped_cache.lock() { + *c = None; + } + if let Ok(mut c) = state.full_transformed_cache.lock() { + *c = None; + } + + // Thumbnails don't refresh from a sidecar edit on their own — regenerate off-thread. + let regen_paths = results.clone(); + let regen_handle = app_handle.clone(); + tauri::async_runtime::spawn_blocking(move || { + crate::file_management::regenerate_thumbnails_for_paths(®en_paths, ®en_handle); + }); + + let _ = app_handle.emit("negatives-converted", &results); + + Ok(results) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stored_negative_reads_the_written_shape() { + let adjustments = serde_json::json!({ + "exposure": 0.3, // an unrelated adjustment coexists in the sidecar + "negativeConversion": { + "enabled": true, + "redWeight": 1.2, "greenWeight": 1.0, "blueWeight": 0.9, + "exposure": 0.5, "contrast": 1.3, + "bounds": [[0.1, 0.8], [0.2, 0.9], [0.15, 0.85]], + } + }); + let (params, bounds) = stored_negative(&adjustments).expect("enabled conversion parses"); + assert!((params.red_weight - 1.2).abs() < 1e-6); + assert!((params.blue_weight - 0.9).abs() < 1e-6); + assert!((params.contrast - 1.3).abs() < 1e-6); + assert!((bounds[0].min - 0.1).abs() < 1e-6); + assert!((bounds[2].max - 0.85).abs() < 1e-6); + + // Absent or disabled → no-op on load. + assert!(stored_negative(&serde_json::json!({})).is_none()); + assert!( + stored_negative(&serde_json::json!({ "negativeConversion": { "enabled": false } })) + .is_none() + ); + } } diff --git a/src/components/modals/AppModals.tsx b/src/components/modals/AppModals.tsx index 137594dc9b..8c2efd28e9 100644 --- a/src/components/modals/AppModals.tsx +++ b/src/components/modals/AppModals.tsx @@ -10,7 +10,6 @@ import { useEditorStore } from '../../store/useEditorStore'; import CopyPasteSettingsModal from './CopyPasteSettingsModal'; import PanoramaModal from './PanoramaModal'; import HdrModal from './HdrModal'; -import NegativeConversionModal from './NegativeConversionModal'; import DenoiseModal from './DenoiseModal'; import CreateFolderModal from './CreateFolderModal'; import RenameFolderModal from './RenameFolderModal'; @@ -69,7 +68,6 @@ export default function AppModals(props: AppModalsProps) { confirmModalState, panoramaModalState, hdrModalState, - negativeModalState, denoiseModalState, cullingModalState, collageModalState, @@ -91,7 +89,6 @@ export default function AppModals(props: AppModalsProps) { confirmModalState: state.confirmModalState, panoramaModalState: state.panoramaModalState, hdrModalState: state.hdrModalState, - negativeModalState: state.negativeModalState, denoiseModalState: state.denoiseModalState, cullingModalState: state.cullingModalState, collageModalState: state.collageModalState, @@ -206,18 +203,6 @@ export default function AppModals(props: AppModalsProps) { onMerge={() => props.handleStartHdr(hdrModalState.stitchingSourcePaths)} progressMessage={hdrModalState.progressMessage} /> - setUI((state) => ({ negativeModalState: { ...state.negativeModalState, isOpen: false } }))} - targetPaths={negativeModalState.targetPaths} - onSave={(savedPaths) => { - props.refreshImageList().then(() => { - if (selectedImage && negativeModalState.targetPaths.includes(selectedImage.path) && savedPaths.length > 0) { - props.handleImageSelect(savedPaths[0]); - } - }); - }} - /> setUI((state) => ({ denoiseModalState: { ...state.denoiseModalState, isOpen: false } }))} diff --git a/src/components/modals/NegativeConversionModal.tsx b/src/components/modals/NegativeConversionModal.tsx deleted file mode 100644 index 23b85990e6..0000000000 --- a/src/components/modals/NegativeConversionModal.tsx +++ /dev/null @@ -1,476 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { useTranslation, Trans } from 'react-i18next'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; -import { RotateCcw, ZoomIn, ZoomOut, Maximize, Save, Loader2, Eye, EyeOff, Info } from 'lucide-react'; -import { AnimatePresence, motion } from 'framer-motion'; -import Button from '../ui/Button'; -import Slider from '../ui/Slider'; -import clsx from 'clsx'; -import throttle from 'lodash.throttle'; -import Text from '../ui/Text'; -import { TextColors, TextVariants } from '../../types/typography'; - -interface NegativeParams { - red_weight: number; - green_weight: number; - blue_weight: number; - contrast: number; - exposure: number; -} - -const DEFAULT_PARAMS: NegativeParams = { - red_weight: 1.0, - green_weight: 1.0, - blue_weight: 1.0, - contrast: 1.0, - exposure: 0.0, -}; - -interface NegativeConversionModalProps { - isOpen: boolean; - onClose(): void; - targetPaths: string[]; - onSave(savedPaths: string[]): void; -} - -export default function NegativeConversionModal({ - isOpen, - onClose, - targetPaths, - onSave, -}: NegativeConversionModalProps) { - const { t } = useTranslation(); - const [params, setParams] = useState(DEFAULT_PARAMS); - const [previewUrl, setPreviewUrl] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [progress, setProgress] = useState<{ current: number; total: number } | null>(null); - - const [isMounted, setIsMounted] = useState(false); - const [show, setShow] = useState(false); - const [zoom, setZoom] = useState(1); - const [pan, setPan] = useState({ x: 0, y: 0 }); - const [isDragging, setIsDragging] = useState(false); - const [isCompareActive, setIsCompareActive] = useState(false); - - const containerRef = useRef(null); - const lastMousePos = useRef({ x: 0, y: 0 }); - const [originalUrl, setOriginalUrl] = useState(null); - const selectedImagePath = targetPaths.length > 0 ? targetPaths[0] : null; - - useEffect(() => { - const unlisten = listen('negative-batch-progress', (e: any) => { - setProgress(e.payload); - }); - return () => { - unlisten.then((f) => f()); - }; - }, []); - - useEffect(() => { - if (!isDragging) return; - const handleWindowMouseMove = (e: MouseEvent) => { - const dx = e.clientX - lastMousePos.current.x; - const dy = e.clientY - lastMousePos.current.y; - setPan((prev) => ({ x: prev.x + dx, y: prev.y + dy })); - lastMousePos.current = { x: e.clientX, y: e.clientY }; - }; - const handleWindowMouseUp = () => setIsDragging(false); - window.addEventListener('mousemove', handleWindowMouseMove); - window.addEventListener('mouseup', handleWindowMouseUp); - return () => { - window.removeEventListener('mousemove', handleWindowMouseMove); - window.removeEventListener('mouseup', handleWindowMouseUp); - }; - }, [isDragging]); - - const handleMouseDown = (e: React.MouseEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - setIsDragging(true); - lastMousePos.current = { x: e.clientX, y: e.clientY }; - }; - - const handleWheel = (e: React.WheelEvent) => { - e.stopPropagation(); - if (!containerRef.current) return; - - const rect = containerRef.current.getBoundingClientRect(); - const mouseX = e.clientX - rect.left - rect.width / 2; - const mouseY = e.clientY - rect.top - rect.height / 2; - const delta = -e.deltaY * 0.001; - const newZoom = Math.min(Math.max(0.1, zoom + delta), 8); - const scaleRatio = newZoom / zoom; - const mouseFromCenterX = mouseX - pan.x; - const mouseFromCenterY = mouseY - pan.y; - const newPanX = mouseX - mouseFromCenterX * scaleRatio; - const newPanY = mouseY - mouseFromCenterY * scaleRatio; - - setZoom(newZoom); - setPan({ x: newPanX, y: newPanY }); - }; - - const updatePreview = useCallback( - throttle(async (currentParams: NegativeParams, isInitialLoad: boolean = false) => { - if (!selectedImagePath) return; - try { - const result: string = await invoke('preview_negative_conversion', { - path: selectedImagePath, - params: currentParams, - }); - setPreviewUrl(result); - if (isInitialLoad) { - setIsLoading(false); - } - } catch (e) { - console.error('Negative preview failed', e); - if (isInitialLoad) { - setIsLoading(false); - } - } - }, 100), - [selectedImagePath], - ); - - useEffect(() => { - if (isOpen) { - setIsMounted(true); - setIsLoading(true); - setTimeout(() => setShow(true), 10); - updatePreview(DEFAULT_PARAMS, true); - - if (selectedImagePath) { - invoke('generate_preview_for_path', { - path: selectedImagePath, - jsAdjustments: {}, - }) - .then((res: any) => { - const blob = new Blob([new Uint8Array(res)], { type: 'image/jpeg' }); - setOriginalUrl(URL.createObjectURL(blob)); - }) - .catch(console.error); - } - } else { - setShow(false); - setTimeout(() => { - setIsMounted(false); - setPreviewUrl(null); - setOriginalUrl(null); - setParams(DEFAULT_PARAMS); - setZoom(1); - setPan({ x: 0, y: 0 }); - setIsLoading(true); - setProgress(null); - }, 300); - } - }, [isOpen, selectedImagePath, updatePreview]); - - const handleParamChange = (key: keyof NegativeParams, value: number) => { - const newParams = { ...params, [key]: value }; - setParams(newParams); - updatePreview(newParams); - }; - - const handleSave = async () => { - if (targetPaths.length === 0) return; - setIsSaving(true); - setProgress(null); - try { - const savedPaths: string[] = await invoke('convert_negatives', { - paths: targetPaths, - params, - }); - onSave(savedPaths); - onClose(); - } catch (e) { - console.error('Failed to batch save negatives', e); - } finally { - setIsSaving(false); - setProgress(null); - } - }; - - const imageTransformStyle = { - transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, - transition: isDragging ? 'none' : 'transform 0.1s ease-out', - transformOrigin: 'center center', - }; - - const renderControls = () => ( -
-
- {t('modals.negativeConversion.title')} - -
- -
-
- - {t('modals.negativeConversion.colorTiming')} - -
- handleParamChange('red_weight', Number(e.target.value))} - fillOrigin="min" - /> - handleParamChange('green_weight', Number(e.target.value))} - fillOrigin="min" - /> - handleParamChange('blue_weight', Number(e.target.value))} - fillOrigin="min" - /> -
-
- -
- - {t('modals.negativeConversion.printGrade')} - -
- handleParamChange('exposure', Number(e.target.value))} - /> - handleParamChange('contrast', Number(e.target.value))} - fillOrigin="min" - /> -
-
- -
- - -
- - Inversion logic inspired by{' '} - - NegPy - {' '} - created by marcinz606 ( - - GPL-3.0 - - ). - -
-
-
-
-
- ); - - const renderContent = () => ( -
-
-
-
- - {isLoading && ( -
- -
- )} - - {(previewUrl || originalUrl) && ( -
-
-
- Preview - {isCompareActive && ( - - {t('modals.negativeConversion.originalLabel')} - - )} -
-
-
- )} - -
e.stopPropagation()} - > - - - {Math.round(zoom * 100)}% - - - -
- -
-
-
- {renderControls()} -
- ); - - if (!isMounted) return null; - - return ( -
- - {show && ( - e.stopPropagation()} - > -
{renderContent()}
- -
- - -
-
- )} -
-
- ); -} diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 0d6d78a605..921fbc3eda 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -247,6 +247,7 @@ export interface Folder { export interface ImageFile { is_edited: boolean; + is_negative: boolean; modified: number; path: string; rating: number; diff --git a/src/hooks/useAppContextMenus.ts b/src/hooks/useAppContextMenus.ts index 6e8dab4608..0f8fc740c3 100644 --- a/src/hooks/useAppContextMenus.ts +++ b/src/hooks/useAppContextMenus.ts @@ -77,6 +77,39 @@ export function useAppContextMenus(props: UseAppContextMenusProps) { useEditorActions(); const { handleRate, handleSetColorLabel, handleTagsChanged } = useLibraryActions(); + const handleSetNegativeConversion = useCallback( + async (paths: string[], enabled: boolean) => { + if (paths.length === 0) return; + paths.forEach((p) => globalImageCache.delete(p)); + try { + await invoke('set_negative_conversion', { paths, enabled }); + // Reflect the new state on the affected items right away so the menu toggles + // Convert/Revert correctly without waiting on the async folder refresh. + useLibraryStore.getState().setLibrary((state) => ({ + imageList: state.imageList.map((img) => + paths.includes(img.path) ? { ...img, is_negative: enabled } : img, + ), + })); + props.refreshImageList(); + const { selectedImage, setEditor, resetHistory } = useEditorStore.getState(); + if (selectedImage && paths.includes(selectedImage.path)) { + const path = selectedImage.path; + setEditor({ hasRenderedFirstFrame: false }); + try { + await invoke(Invokes.LoadImage, { path }); + const metadata: any = await invoke(Invokes.LoadMetadata, { path }); + resetHistory(normalizeLoadedAdjustments(metadata.adjustments)); + } catch { + // ignore; the grid refresh above still reflects the change + } + } + } catch (e) { + toast.error(`Negative conversion failed: ${e}`); + } + }, + [props], + ); + const albumIcons = useMemo( () => [ { label: t('contextMenus.albumIcons.default'), value: undefined, icon: Folder }, @@ -221,11 +254,14 @@ export function useAppContextMenus(props: UseAppContextMenusProps) { }, }, { - label: t('contextMenus.editor.convertNegative'), + label: useEditorStore.getState().adjustments?.negativeConversion?.enabled + ? t('contextMenus.editor.revertNegative') + : t('contextMenus.editor.convertNegative'), icon: Film, onClick: () => { if (selectedImage) { - setUI({ negativeModalState: { isOpen: true, targetPaths: [selectedImage.path] } }); + const enabled = !useEditorStore.getState().adjustments?.negativeConversion?.enabled; + handleSetNegativeConversion([selectedImage.path], enabled); } }, }, @@ -411,7 +447,11 @@ export function useAppContextMenus(props: UseAppContextMenusProps) { const cullLabel = t('contextMenus.thumbnail.cullImage', { count: selectionCount }); const collageLabel = t('contextMenus.thumbnail.collage', { count: selectionCount }); const stitchLabel = t('contextMenus.editor.stitchPanorama'); - const conversionLabel = t('contextMenus.thumbnail.convertNegative', { count: selectionCount }); + const selectedFiles = imageList.filter((img) => finalSelection.includes(img.path)); + const allNegative = selectedFiles.length > 0 && selectedFiles.every((f) => f.is_negative); + const conversionLabel = allNegative + ? t('contextMenus.thumbnail.revertNegative', { count: selectionCount }) + : t('contextMenus.thumbnail.convertNegative', { count: selectionCount }); const denoiseLabel = t('contextMenus.thumbnail.denoise', { count: selectionCount }); const mergeLabel = t('contextMenus.editor.mergeHdr'); @@ -567,7 +607,7 @@ export function useAppContextMenus(props: UseAppContextMenusProps) { icon: Film, disabled: selectionCount === 0, onClick: () => { - setUI({ negativeModalState: { isOpen: true, targetPaths: finalSelection } }); + handleSetNegativeConversion(finalSelection, !allNegative); }, }, { diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index db61e463d0..8b7eae5a0c 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -527,8 +527,7 @@ export const useKeyboardShortcuts = ({ state.ui.panoramaModalState.isOpen || state.ui.cullingModalState.isOpen || state.ui.collageModalState.isOpen || - state.ui.denoiseModalState.isOpen || - state.ui.negativeModalState.isOpen; + state.ui.denoiseModalState.isOpen; if (isModalOpen) return; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e9665b760e..ade341ee1b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -175,6 +175,7 @@ "colorLabel": "Color Label", "confirmReset": "Confirm Reset", "convertNegative": "Convert Negative", + "revertNegative": "Revert to Negative", "copyAdjustments": "Copy Adjustments", "cullImage": "Cull Image", "denoise": "Denoise Image", @@ -235,6 +236,8 @@ "confirmDeleteVc": "Confirm Delete + Virtual Copies", "convertNegative_one": "Convert Negative", "convertNegative_other": "Convert Negatives", + "revertNegative_one": "Revert to Negative", + "revertNegative_other": "Revert to Negatives", "copyImage_one": "Copy Image", "copyImage_other": "Copy {{count}} Images", "cullImage_one": "Cull Image", diff --git a/src/store/useUIStore.ts b/src/store/useUIStore.ts index f49eedfb6a..f16f6f32dc 100644 --- a/src/store/useUIStore.ts +++ b/src/store/useUIStore.ts @@ -62,10 +62,6 @@ export interface DenoiseModalState { isRaw: boolean; } -export interface NegativeConversionModalState { - isOpen: boolean; - targetPaths: Array; -} export interface CullingModalState { isOpen: boolean; @@ -118,7 +114,6 @@ interface UIState { confirmModalState: ConfirmModalState; panoramaModalState: PanoramaModalState; hdrModalState: HdrModalState; - negativeModalState: NegativeConversionModalState; denoiseModalState: DenoiseModalState; cullingModalState: CullingModalState; collageModalState: CollageModalState; @@ -181,7 +176,6 @@ export const useUIStore = create((set, get) => ({ progressMessage: '', stitchingSourcePaths: [], }, - negativeModalState: { isOpen: false, targetPaths: [] }, denoiseModalState: { isOpen: false, isProcessing: false,