diff --git a/FORK_NOTES.md b/FORK_NOTES.md new file mode 100644 index 0000000000..bb32e8c4c1 --- /dev/null +++ b/FORK_NOTES.md @@ -0,0 +1,104 @@ +# Fork notes (SandeepSubba/RapidRAW) + +This fork tracks the upstream project [`CyberTimon/RapidRAW`](https://github.com/CyberTimon/RapidRAW) +and adds a small set of custom changes on top. This file documents what we +changed and how to re-apply it cleanly when upstream releases a new version. + +## Git remotes + +``` +origin -> https://github.com/CyberTimon/RapidRAW.git (upstream / the original author) +fork -> https://github.com/SandeepSubba/RapidRAW.git (this fork) +``` + +> If `origin` points at the fork on your machine, rename it: `git remote rename +> origin fork` and add upstream as `git remote add origin https://github.com/CyberTimon/RapidRAW.git`. + +## Branches + +| Branch | Contents | Purpose | +| ------ | -------- | ------- | +| `custom-shortcuts` | backend hardening + refactors **+ the adjustment shortcuts** + this doc | The full fork — **run and rebase this one.** | +| `code-analysis-fixes` | backend hardening + refactors only | Clean subset to open as a PR to upstream. Does **not** include the personal shortcut feature. | + +The two share the same 3 fix/refactor commits, so opening the PR from +`code-analysis-fixes` keeps the personal shortcuts out of the contribution. + +## Custom changes in this fork + +All changes are kept **small, isolated, and additive** specifically so they +survive upstream updates with minimal merge friction. + +### 1. Capture One–style adjustment shortcuts (`feat:` commit) +Increase/decrease keyboard shortcuts for the core tonal & color sliders, shown +in **Settings → Controls → Adjustments** and fully remappable. + +| File | Change | Conflict risk | +| ---- | ------ | ------------- | +| `src/utils/keyboardUtils.ts` | adds the `adjustments` section + the `ADJUSTMENT_NUDGES` table, spread into `KEYBIND_DEFINITIONS` | low (additive) | +| `src/hooks/useKeyboardShortcuts.ts` | one generated handler per nudge, after the `actions` map | low (additive) | +| `src/i18n/locales/en.json` | section label + action labels | low (additive) | + +To change/extend: edit the single `ADJUSTMENT_NUDGES` array in +`keyboardUtils.ts` (combo, target adjustment key, step, clamp range). The +dispatcher and the keybind UI are both driven from it, so nothing else needs +to change. + +### 2. Metadata export-naming tokens + single-image file naming (`feat:` commit) +Export filename templates gain metadata tokens that mirror the Metadata panel's +editable fields, the File Naming section shows for single-image export (not just +batch), and unknown tokens fall back to the default template. + +| File | Change | Conflict risk | +| ---- | ------ | ------------- | +| `src-tauri/src/file_management.rs` | `{title}`/`{author}`/`{copyright}`/`{comments}` substitution + `sanitize_filename_component` + `generate_export_filename` command | low (additive) | +| `src-tauri/src/lib.rs` | registers `generate_export_filename` | low (additive) | +| `src/components/ui/ExportImportProperties.tsx` | new token list entries + `DEFAULT_FILENAME_TEMPLATE` + `sanitizeFilenameTemplate` | low (additive) | +| `src/components/panel/right/ExportPanel.tsx` | show naming UI for single image; resolve single-image name via backend | low | +| `src/hooks/useExportSettings.ts` | sanitize template on preset apply | low | +| `src/components/ui/AppProperties.tsx` | `GenerateExportFilename` invoke enum entry | low (additive) | + +To add a metadata token: add the substitution in `generate_filename_from_template` +and the `{token}` string to `FILENAME_VARIABLES`. + +### 3. Backend hardening + refactors (`fix:` / `refactor:` commits) +See the `code-analysis-fixes` branch / the open PR to upstream. These are +candidates to be merged upstream; if they are, drop them from the fork. + +## Updating when upstream releases a new version + +```bash +# 1. Get the latest upstream code +git fetch origin + +# 2. Replay our custom commits on top of the new upstream main +git checkout custom-shortcuts +git rebase origin/main + +# 3. If a conflict appears (rare, since our changes are additive), fix the +# file, then: +git add +git rebase --continue + +# 4. Reinstall deps in case package.json changed upstream, then test +npm install +npm start + +# 5. Update the fork +git push --force-with-lease fork custom-shortcuts +``` + +Because the feature lives in one additive commit, the usual outcome of step 2 +is a clean replay with no conflicts. If upstream ever restructures +`keyboardUtils.ts` or `useKeyboardShortcuts.ts`, the only fix-up needed is to +re-add the `ADJUSTMENT_NUDGES` block and its dispatch loop — both are clearly +commented in the source. + +## Build / run + +```bash +npm install # Node.js LTS + Rust toolchain required +npm run typecheck # note: upstream has pre-existing strict-tsc errors; the + # Vite/esbuild build does not gate on them +npm start # tauri dev — builds the Rust backend and launches the app +``` diff --git a/src-tauri/src/ai_processing.rs b/src-tauri/src/ai_processing.rs index ca2fdd06cb..88499f25dc 100644 --- a/src-tauri/src/ai_processing.rs +++ b/src-tauri/src/ai_processing.rs @@ -875,6 +875,16 @@ pub fn run_lama_inpainting( outputs[0].try_extract_array::()?.to_owned() }; + let out_dims = output_tensor.shape(); + if out_dims.len() < 4 || (out_dims[2] as u32) < fh || (out_dims[3] as u32) < fw { + return Err(anyhow::anyhow!( + "LaMa output shape {:?} smaller than expected {}x{}", + out_dims, + fw, + fh + )); + } + let mut result_inf = RgbaImage::new(fw, fh); for y in 0..fh { for x in 0..fw { @@ -1045,11 +1055,27 @@ pub fn run_sam_decoder( }; let mask_dims = mask_tensor.shape(); + if mask_dims.len() < 4 { + return Err(anyhow::anyhow!( + "Unexpected SAM decoder output rank: expected 4 dims, got {:?}", + mask_dims + )); + } let h = mask_dims[2]; let w = mask_dims[3]; let area = h * w; - let mask_slice = mask_tensor.as_slice().unwrap(); + let mask_slice = mask_tensor + .as_slice() + .ok_or_else(|| anyhow::anyhow!("SAM decoder output tensor is not contiguous"))?; + if mask_slice.len() < area { + return Err(anyhow::anyhow!( + "SAM decoder output too small: {} elements for {}x{} mask", + mask_slice.len(), + w, + h + )); + } let first_mask_slice = &mask_slice[0..area]; if i == iters - 1 { @@ -1156,9 +1182,11 @@ pub fn run_sam_decoder( .collect(); let img_mask_f32 = - ImageBuffer::, Vec>::from_raw(w as u32, h as u32, mask_f32_vec).unwrap(); + ImageBuffer::, Vec>::from_raw(w as u32, h as u32, mask_f32_vec) + .ok_or_else(|| anyhow::anyhow!("Failed to build mask buffer from SAM output"))?; let img_gaus_f32 = - ImageBuffer::, Vec>::from_raw(w as u32, h as u32, gaus_dt).unwrap(); + ImageBuffer::, Vec>::from_raw(w as u32, h as u32, gaus_dt) + .ok_or_else(|| anyhow::anyhow!("Failed to build gaussian buffer from SAM output"))?; let resized_mask = imageops::resize(&img_mask_f32, 256, 256, FilterType::Triangle); let resized_gaus = imageops::resize(&img_gaus_f32, 256, 256, FilterType::Triangle); @@ -1177,7 +1205,7 @@ pub fn run_sam_decoder( } mask_input = Array::from_shape_vec((1, 1, 256, 256), mask_input_flat) - .unwrap() + .map_err(|e| anyhow::anyhow!("Failed to reshape SAM mask input: {e}"))? .into_dyn(); has_mask_input = 1.0; } @@ -1234,7 +1262,17 @@ pub fn run_sky_seg_model( let mut session = sky_seg_session.lock().unwrap(); let outputs = session.run(ort::inputs![t_input])?; let output_tensor = outputs[0].try_extract_array::()?.to_owned(); - let out_slice = output_tensor.as_slice().unwrap(); + let usize_size = SKYSEG_INPUT_SIZE as usize; + let out_slice = output_tensor + .as_slice() + .ok_or_else(|| anyhow::anyhow!("Sky Segmentation output tensor is not contiguous"))?; + if out_slice.len() < usize_size * usize_size { + return Err(anyhow::anyhow!( + "Sky Segmentation output too small: {} elements, need {}", + out_slice.len(), + usize_size * usize_size + )); + } let mut min_val = f32::MAX; let mut max_val = f32::MIN; @@ -1246,7 +1284,6 @@ pub fn run_sky_seg_model( let range = max_val - min_val; let scale = if range > 1e-6 { 255.0 / range } else { 0.0 }; - let usize_size = SKYSEG_INPUT_SIZE as usize; let mut cropped_mask_data = Vec::with_capacity(rw * rh); for y in 0..rh { @@ -1315,7 +1352,17 @@ pub fn run_u2netp_model( let mut session = u2netp_session.lock().unwrap(); let outputs = session.run(ort::inputs![t_input])?; let output_tensor = outputs[0].try_extract_array::()?.to_owned(); - let out_slice = output_tensor.as_slice().unwrap(); + let usize_size = U2NETP_INPUT_SIZE as usize; + let out_slice = output_tensor + .as_slice() + .ok_or_else(|| anyhow::anyhow!("U-2-Netp output tensor is not contiguous"))?; + if out_slice.len() < usize_size * usize_size { + return Err(anyhow::anyhow!( + "U-2-Netp output too small: {} elements, need {}", + out_slice.len(), + usize_size * usize_size + )); + } let mut min_val = f32::MAX; let mut max_val = f32::MIN; @@ -1327,7 +1374,6 @@ pub fn run_u2netp_model( let range = max_val - min_val; let scale = if range > 1e-6 { 255.0 / range } else { 0.0 }; - let usize_size = U2NETP_INPUT_SIZE as usize; let mut cropped_mask_data = Vec::with_capacity(rw * rh); for y in 0..rh { @@ -1394,9 +1440,17 @@ pub fn run_depth_anything_model( let mut session = depth_session.lock().unwrap(); let outputs = session.run(ort::inputs![t_input])?; let output_tensor = outputs[0].try_extract_array::()?.to_owned(); - let out_slice = output_tensor.as_slice().unwrap(); - let usize_size = DEPTH_INPUT_SIZE as usize; + let out_slice = output_tensor + .as_slice() + .ok_or_else(|| anyhow::anyhow!("Depth Anything output tensor is not contiguous"))?; + if out_slice.len() < usize_size * usize_size { + return Err(anyhow::anyhow!( + "Depth Anything output too small: {} elements, need {}", + out_slice.len(), + usize_size * usize_size + )); + } let mut min_val = f32::MAX; let mut max_val = f32::MIN; diff --git a/src-tauri/src/cache_utils.rs b/src-tauri/src/cache_utils.rs index 63084ee53d..416a64209a 100644 --- a/src-tauri/src/cache_utils.rs +++ b/src-tauri/src/cache_utils.rs @@ -112,26 +112,26 @@ pub fn calculate_transform_hash(adjustments: &serde_json::Value) -> u64 { is_visible.hash(&mut hasher); if let Some(patch_data) = patch.get("patchData") { - let color_len = patch_data + // Hash the full base64 content, not just its length: two different + // patch results of identical dimensions encode to the same length + // and would otherwise collide, leaving the stale patch composited. + patch_data .get("color") .and_then(|v| v.as_str()) .unwrap_or("") - .len(); - color_len.hash(&mut hasher); + .hash(&mut hasher); - let mask_len = patch_data + patch_data .get("mask") .and_then(|v| v.as_str()) .unwrap_or("") - .len(); - mask_len.hash(&mut hasher); + .hash(&mut hasher); } else { - let data_len = patch + patch .get("patchDataBase64") .and_then(|v| v.as_str()) .unwrap_or("") - .len(); - data_len.hash(&mut hasher); + .hash(&mut hasher); } if let Some(sub_masks_val) = patch.get("subMasks") { diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index 2e4b1db421..270cd5654e 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -162,6 +162,12 @@ fn calculate_resize_target( current_h: u32, resize_opts: &ResizeOptions, ) -> (u32, u32) { + // Guard against zero-dimension images: the aspect-ratio math below divides by + // current_w / current_h and would otherwise produce Inf/NaN. + if current_w == 0 || current_h == 0 { + return (current_w, current_h); + } + if resize_opts.dont_enlarge { let exceeds = match resize_opts.mode { ResizeMode::LongEdge => current_w.max(current_h) > resize_opts.value, @@ -369,7 +375,9 @@ fn build_single_mask_adjustments(all: &AllAdjustments, mask_index: usize) -> All tile_offset_y: all.tile_offset_y, mask_atlas_cols: all.mask_atlas_cols, }; - single.mask_adjustments[0] = all.mask_adjustments[mask_index]; + if mask_index < all.mask_adjustments.len() { + single.mask_adjustments[0] = all.mask_adjustments[mask_index]; + } for i in 1..single.mask_adjustments.len() { single.mask_adjustments[i] = Default::default(); } diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 43d8fac765..e601aaa4d5 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -43,6 +43,11 @@ use crate::mask_generation::MaskDefinition; use crate::preset_converter; use crate::tagging::COLOR_TAG_PREFIX; +mod albums; +pub use albums::*; +mod folder_tree; +pub use folder_tree::*; + fn resolve_thumbnail_cache_dir(app_handle: &AppHandle) -> std::result::Result { let cache_dir = app_handle .path() @@ -513,537 +518,6 @@ pub fn list_images_recursive( Ok(result_list) } -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum AlbumItem { - Album { - id: String, - name: String, - icon: Option, - images: Vec, - }, - Group { - id: String, - name: String, - icon: Option, - children: Vec, - }, -} - -fn get_albums_path(app_handle: &AppHandle) -> Result { - let data_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| e.to_string())?; - let albums_dir = data_dir.join("albums"); - if !albums_dir.exists() { - fs::create_dir_all(&albums_dir).map_err(|e| e.to_string())?; - } - Ok(albums_dir.join("albums.json")) -} - -pub fn sort_album_tree(items: &mut [AlbumItem]) { - items.sort_by(|a, b| { - let get_sort_key = |item: &AlbumItem| match item { - AlbumItem::Group { name, .. } => (0, name.to_lowercase()), - AlbumItem::Album { name, .. } => (1, name.to_lowercase()), - }; - - let key_a = get_sort_key(a); - let key_b = get_sort_key(b); - - key_a.cmp(&key_b) - }); - - for item in items.iter_mut() { - if let AlbumItem::Group { children, .. } = item { - sort_album_tree(children); - } - } -} - -#[tauri::command] -pub fn get_albums(app_handle: AppHandle) -> Result, String> { - let path = get_albums_path(&app_handle)?; - if !path.exists() { - return Ok(Vec::new()); - } - let content = fs::read_to_string(path).map_err(|e| e.to_string())?; - let mut items: Vec = serde_json::from_str(&content).map_err(|e| e.to_string())?; - sort_album_tree(&mut items); - Ok(items) -} - -#[tauri::command] -pub fn save_albums(mut tree: Vec, app_handle: AppHandle) -> Result<(), String> { - let path = get_albums_path(&app_handle)?; - sort_album_tree(&mut tree); - let json_string = serde_json::to_string_pretty(&tree).map_err(|e| e.to_string())?; - fs::write(path, json_string).map_err(|e| e.to_string()) -} - -#[tauri::command] -pub fn add_to_album( - album_id: String, - paths: Vec, - app_handle: AppHandle, -) -> Result<(), String> { - let mut tree = get_albums(app_handle.clone())?; - - fn add_recursive(items: &mut [AlbumItem], target_id: &str, paths_to_add: &Vec) -> bool { - for item in items.iter_mut() { - #[allow(clippy::collapsible_match)] - match item { - AlbumItem::Album { id, images, .. } if id == target_id => { - for p in paths_to_add { - if !images.contains(p) { - images.push(p.clone()); - } - } - return true; - } - AlbumItem::Group { children, .. } => { - if add_recursive(children, target_id, paths_to_add) { - return true; - } - } - _ => {} - } - } - false - } - - if add_recursive(&mut tree, &album_id, &paths) { - save_albums(tree, app_handle)?; - } - Ok(()) -} - -fn sync_album_path_changes( - app_handle: &AppHandle, - renames: Option<&HashMap>, - deletions: Option<&HashSet>, - folder_rename: Option<(&str, &str)>, -) { - if let Ok(mut tree) = get_albums(app_handle.clone()) { - let mut changed = false; - - fn process_nodes( - nodes: &mut [AlbumItem], - renames: Option<&HashMap>, - deletions: Option<&HashSet>, - folder_rename: Option<(&str, &str)>, - changed: &mut bool, - ) { - for node in nodes.iter_mut() { - match node { - AlbumItem::Album { images, .. } => { - let mut new_images = Vec::new(); - - for img in images.drain(..) { - let mut current_img = img; - - if let Some((old_folder, new_folder)) = folder_rename { - let img_path = Path::new(¤t_img); - let old_path = Path::new(old_folder); - if let Ok(stripped) = img_path.strip_prefix(old_path) { - let new_img_path = Path::new(new_folder).join(stripped); - current_img = new_img_path.to_string_lossy().into_owned(); - *changed = true; - } - } - - if let Some(r) = renames { - if let Some(new_path) = r.get(¤t_img) { - current_img = new_path.clone(); - *changed = true; - } else if let Some((base_path, vc_id)) = - current_img.rsplit_once("?vc=") - && let Some(new_base) = r.get(base_path) - { - current_img = format!("{}?vc={}", new_base, vc_id); - *changed = true; - } - } - - let mut is_deleted = false; - if let Some(d) = deletions { - if d.contains(¤t_img) { - is_deleted = true; - } else { - let img_path = Path::new(¤t_img); - for del_path_str in d { - let del_path = Path::new(del_path_str); - if img_path.starts_with(del_path) { - is_deleted = true; - break; - } - - if let Some((base_path, _)) = - current_img.rsplit_once("?vc=") - && base_path == del_path_str - { - is_deleted = true; - break; - } - } - } - } - - if !is_deleted { - new_images.push(current_img); - } else { - *changed = true; - } - } - *images = new_images; - } - AlbumItem::Group { children, .. } => { - process_nodes(children, renames, deletions, folder_rename, changed); - } - } - } - } - - process_nodes(&mut tree, renames, deletions, folder_rename, &mut changed); - - if changed { - let _ = save_albums(tree, app_handle.clone()); - } - } -} - -#[tauri::command] -pub fn get_album_images( - paths: Vec, - app_handle: AppHandle, -) -> Result, String> { - let settings = load_settings(app_handle.clone()).unwrap_or_default(); - let enable_xmp_sync = settings.enable_xmp_sync.unwrap_or(false); - - let result_list: Vec = paths - .into_par_iter() - .filter_map(|virtual_path| { - let (source_path, sidecar_path) = parse_virtual_path(&virtual_path); - if !source_path.exists() { - return None; - } - - let modified = fs::metadata(&source_path) - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0); - - let is_virtual_copy = virtual_path.contains("?vc="); - - let (is_edited, tags, rating) = { - let mut metadata = crate::exif_processing::load_sidecar(&sidecar_path); - - if enable_xmp_sync - && sync_metadata_from_xmp(&source_path, &mut metadata) - && let Ok(json) = serde_json::to_string_pretty(&metadata) - { - let _ = fs::write(&sidecar_path, json); - } - - let is_raw = crate::formats::is_raw_file(&source_path); - let tm_override = - crate::image_processing::resolve_tonemapper_override(&settings, is_raw); - let edited = crate::image_processing::is_image_edited( - &metadata.adjustments, - is_raw, - tm_override, - ); - (edited, metadata.tags, metadata.rating) - }; - - Some(ImageFile { - path: virtual_path, - modified, - is_edited, - tags, - exif: None, - is_virtual_copy, - rating, - }) - }) - .collect(); - - Ok(result_list) -} - -#[derive(Serialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct FolderNode { - pub name: String, - pub path: String, - pub children: Vec, - pub is_dir: bool, - pub image_count: usize, - pub has_subdirs: bool, - pub modified: u64, - pub created: u64, -} - -fn has_subdirs(path: &Path) -> bool { - if let Ok(entries) = std::fs::read_dir(path) { - for entry in entries.filter_map(Result::ok) { - if let Ok(file_type) = entry.file_type() - && file_type.is_dir() - { - let name = entry.file_name(); - if !name.to_string_lossy().starts_with('.') { - return true; - } - } - } - } - false -} - -fn scan_dir_lazy( - path: &Path, - expanded_folders: &HashSet<&str>, - show_image_counts: bool, - prefetch_one_level: bool, -) -> Result<(Vec, usize), std::io::Error> { - let mut children_folders = Vec::new(); - let mut current_dir_image_count = 0; - - let entries = match std::fs::read_dir(path) { - Ok(entries) => entries, - Err(e) => { - log::warn!("Could not scan directory '{}': {}", path.display(), e); - return Ok((Vec::new(), 0)); - } - }; - - for entry in entries.filter_map(Result::ok) { - let current_path = entry.path(); - let (file_type, modified, created) = match entry.metadata() { - Ok(meta) => { - let ft = meta.file_type(); - let mod_time = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH); - let cre_time = meta.created().unwrap_or(mod_time); - - ( - ft, - mod_time - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - cre_time - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ) - } - Err(_) => continue, - }; - - let file_name = entry.file_name(); - let name_str = file_name.to_string_lossy(); - - if name_str.starts_with('.') { - continue; - } - - if file_type.is_dir() { - let path_str = current_path.to_string_lossy().into_owned(); - let is_expanded = expanded_folders.contains(path_str.as_str()); - - let should_scan = is_expanded || prefetch_one_level; - let next_prefetch = is_expanded; - - let (grand_children, sub_dir_own_images) = if should_scan { - scan_dir_lazy( - ¤t_path, - expanded_folders, - show_image_counts, - next_prefetch, - )? - } else { - let count = if show_image_counts { - WalkDir::new(¤t_path) - .into_iter() - .filter_map(Result::ok) - .filter(|e| { - e.file_type().is_file() - && crate::formats::is_supported_image_file(e.path()) - }) - .count() - } else { - 0 - }; - (Vec::new(), count) - }; - - let has_any_subdirs = if should_scan { - grand_children.iter().any(|c| c.is_dir) - } else { - has_subdirs(¤t_path) - }; - - let grand_children_sum: usize = grand_children.iter().map(|c| c.image_count).sum(); - let total_child_count = sub_dir_own_images + grand_children_sum; - - children_folders.push(FolderNode { - name: name_str.into_owned(), - path: path_str, - children: grand_children, - is_dir: true, - image_count: total_child_count, - has_subdirs: has_any_subdirs, - modified, - created, - }); - } else if show_image_counts - && file_type.is_file() - && crate::formats::is_supported_image_file(¤t_path) - { - current_dir_image_count += 1; - } - } - - children_folders.sort_by_key(|a| a.name.to_lowercase()); - - Ok((children_folders, current_dir_image_count)) -} - -fn get_folder_tree_sync( - path: String, - expanded_folders: Vec, - show_image_counts: bool, -) -> Result { - let root_path = Path::new(&path); - if !root_path.is_dir() { - return Err(format!("Directory does not exist: {}", path)); - } - - let (modified, created) = root_path - .metadata() - .map(|m| { - let mod_time = m.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH); - let cre_time = m.created().unwrap_or(mod_time); - ( - mod_time - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - cre_time - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ) - }) - .unwrap_or((0, 0)); - - let expanded_set: HashSet<&str> = expanded_folders.iter().map(|s| s.as_str()).collect(); - - let (children, own_count) = scan_dir_lazy(root_path, &expanded_set, show_image_counts, true) - .map_err(|e| e.to_string())?; - - let children_sum: usize = children.iter().map(|c| c.image_count).sum(); - let has_subdirs = children.iter().any(|c| c.is_dir); - - let name = match root_path.file_name() { - Some(n) => n.to_string_lossy().into_owned(), - None => { - let trimmed = path.trim_end_matches(&['/', '\\'][..]); - if trimmed.is_empty() { - path.clone() - } else { - trimmed.to_string() - } - } - }; - - Ok(FolderNode { - name, - path: path.clone(), - children, - is_dir: true, - image_count: own_count + children_sum, - has_subdirs, - modified, - created, - }) -} - -#[tauri::command] -pub async fn get_folder_children( - path: String, - show_image_counts: bool, -) -> Result, String> { - match tauri::async_runtime::spawn_blocking(move || { - let root_path = Path::new(&path); - if !root_path.is_dir() { - return Err(format!("Directory does not exist: {}", path)); - } - let empty_set = HashSet::new(); - let (children, _) = scan_dir_lazy(root_path, &empty_set, show_image_counts, false) - .map_err(|e| e.to_string())?; - - Ok(children) - }) - .await - { - Ok(Ok(children)) => Ok(children), - Ok(Err(e)) => Err(e), - Err(e) => Err(format!("Task failed: {}", e)), - } -} - -#[tauri::command] -pub async fn get_folder_tree( - path: String, - expanded_folders: Vec, - show_image_counts: bool, -) -> Result { - match tauri::async_runtime::spawn_blocking(move || { - get_folder_tree_sync(path, expanded_folders, show_image_counts) - }) - .await - { - Ok(Ok(folder_node)) => Ok(folder_node), - Ok(Err(e)) => Err(e), - Err(e) => Err(format!("Failed to execute folder tree task: {}", e)), - } -} - -#[tauri::command] -pub async fn get_pinned_folder_trees( - paths: Vec, - expanded_folders: Vec, - show_image_counts: bool, -) -> Result, String> { - let result = tauri::async_runtime::spawn_blocking(move || { - let results: Vec> = paths - .par_iter() - .map(|path| { - get_folder_tree_sync(path.clone(), expanded_folders.clone(), show_image_counts) - }) - .collect(); - - let mut folder_nodes = Vec::new(); - for result in results { - match result { - Ok(node) => folder_nodes.push(node), - Err(e) => log::warn!("Failed to get tree for pinned folder: {}", e), - } - } - folder_nodes - }) - .await; - - match result { - Ok(nodes) => Ok(nodes), - Err(e) => Err(format!("Task failed: {}", e)), - } -} - pub fn read_file_mapped(path: &Path) -> Result { if !path.is_file() { return Err(ReadFileError::Invalid); @@ -3261,6 +2735,22 @@ pub async fn import_files( Ok(()) } +/// Make an EXIF text value safe to use as part of a filename: strip characters +/// that are invalid on Windows/Unix or could escape the target directory, +/// collapse whitespace, trim trailing dots, and cap the length. +fn sanitize_filename_component(value: &str) -> String { + let cleaned: String = value + .chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => ' ', + c if c.is_control() => ' ', + c => c, + }) + .collect(); + let collapsed = cleaned.split_whitespace().collect::>().join(" "); + collapsed.trim_matches('.').trim().chars().take(100).collect() +} + pub fn generate_filename_from_template( template: &str, original_path: &std::path::Path, @@ -3288,9 +2778,58 @@ pub fn generate_filename_from_template( result = result.replace("{hh}", &local_date.format("%H").to_string()); result = result.replace("{mm}", &local_date.format("%M").to_string()); + // Metadata tokens, named to match the Metadata panel's editable fields + // (Title / Author / Copyright / Comments). Read lazily (only when used) from + // the image's cached EXIF in the .rrdata sidecar, so unrelated callers pay no + // I/O cost. + if result.contains("{title}") + || result.contains("{author}") + || result.contains("{copyright}") + || result.contains("{comments}") + { + let exif = original_path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| original_path.with_file_name(format!("{}.rrdata", n))) + .map(|sidecar| crate::exif_processing::load_sidecar(&sidecar).exif) + .unwrap_or_default() + .unwrap_or_default(); + let lookup = |keys: &[&str]| -> String { + keys.iter() + .find_map(|k| exif.get(*k)) + .map(|v| sanitize_filename_component(v)) + .unwrap_or_default() + }; + result = result.replace("{title}", &lookup(&["ImageDescription", "XPTitle"])); + result = result.replace("{author}", &lookup(&["Artist"])); + result = result.replace("{copyright}", &lookup(&["Copyright"])); + result = result.replace("{comments}", &lookup(&["UserComment", "XPComment"])); + } + result } +/// Resolve a single export filename stem from a template for one image. Used by +/// the export panel to build the suggested name shown in the save dialog, so the +/// same tokens (dates, metadata, original filename) work for single-image export +/// as for batch export. Falls back to the original stem if the result is empty. +#[tauri::command] +pub fn generate_export_filename(path: String, template: String) -> String { + let (source_path, _) = parse_virtual_path(&path); + let file_date = crate::exif_processing::get_creation_date_from_path(&source_path); + let stem = generate_filename_from_template(&template, &source_path, 1, 1, &file_date); + let trimmed = stem.trim(); + if trimmed.is_empty() { + source_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("image") + .to_string() + } else { + trimmed.to_string() + } +} + #[tauri::command] pub fn rename_files( paths: Vec, diff --git a/src-tauri/src/file_management/albums.rs b/src-tauri/src/file_management/albums.rs new file mode 100644 index 0000000000..1235d4430f --- /dev/null +++ b/src-tauri/src/file_management/albums.rs @@ -0,0 +1,262 @@ +use super::*; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum AlbumItem { + Album { + id: String, + name: String, + icon: Option, + images: Vec, + }, + Group { + id: String, + name: String, + icon: Option, + children: Vec, + }, +} + +pub(crate) fn get_albums_path(app_handle: &AppHandle) -> Result { + let data_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| e.to_string())?; + let albums_dir = data_dir.join("albums"); + if !albums_dir.exists() { + fs::create_dir_all(&albums_dir).map_err(|e| e.to_string())?; + } + Ok(albums_dir.join("albums.json")) +} + +pub fn sort_album_tree(items: &mut [AlbumItem]) { + items.sort_by(|a, b| { + let get_sort_key = |item: &AlbumItem| match item { + AlbumItem::Group { name, .. } => (0, name.to_lowercase()), + AlbumItem::Album { name, .. } => (1, name.to_lowercase()), + }; + + let key_a = get_sort_key(a); + let key_b = get_sort_key(b); + + key_a.cmp(&key_b) + }); + + for item in items.iter_mut() { + if let AlbumItem::Group { children, .. } = item { + sort_album_tree(children); + } + } +} + +#[tauri::command] +pub fn get_albums(app_handle: AppHandle) -> Result, String> { + let path = get_albums_path(&app_handle)?; + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(path).map_err(|e| e.to_string())?; + let mut items: Vec = serde_json::from_str(&content).map_err(|e| e.to_string())?; + sort_album_tree(&mut items); + Ok(items) +} + +#[tauri::command] +pub fn save_albums(mut tree: Vec, app_handle: AppHandle) -> Result<(), String> { + let path = get_albums_path(&app_handle)?; + sort_album_tree(&mut tree); + let json_string = serde_json::to_string_pretty(&tree).map_err(|e| e.to_string())?; + fs::write(path, json_string).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_to_album( + album_id: String, + paths: Vec, + app_handle: AppHandle, +) -> Result<(), String> { + let mut tree = get_albums(app_handle.clone())?; + + fn add_recursive(items: &mut [AlbumItem], target_id: &str, paths_to_add: &Vec) -> bool { + for item in items.iter_mut() { + #[allow(clippy::collapsible_match)] + match item { + AlbumItem::Album { id, images, .. } if id == target_id => { + for p in paths_to_add { + if !images.contains(p) { + images.push(p.clone()); + } + } + return true; + } + AlbumItem::Group { children, .. } => { + if add_recursive(children, target_id, paths_to_add) { + return true; + } + } + _ => {} + } + } + false + } + + if add_recursive(&mut tree, &album_id, &paths) { + save_albums(tree, app_handle)?; + } + Ok(()) +} + +pub(crate) fn sync_album_path_changes( + app_handle: &AppHandle, + renames: Option<&HashMap>, + deletions: Option<&HashSet>, + folder_rename: Option<(&str, &str)>, +) { + if let Ok(mut tree) = get_albums(app_handle.clone()) { + let mut changed = false; + + fn process_nodes( + nodes: &mut [AlbumItem], + renames: Option<&HashMap>, + deletions: Option<&HashSet>, + folder_rename: Option<(&str, &str)>, + changed: &mut bool, + ) { + for node in nodes.iter_mut() { + match node { + AlbumItem::Album { images, .. } => { + let mut new_images = Vec::new(); + + for img in images.drain(..) { + let mut current_img = img; + + if let Some((old_folder, new_folder)) = folder_rename { + let img_path = Path::new(¤t_img); + let old_path = Path::new(old_folder); + if let Ok(stripped) = img_path.strip_prefix(old_path) { + let new_img_path = Path::new(new_folder).join(stripped); + current_img = new_img_path.to_string_lossy().into_owned(); + *changed = true; + } + } + + if let Some(r) = renames { + if let Some(new_path) = r.get(¤t_img) { + current_img = new_path.clone(); + *changed = true; + } else if let Some((base_path, vc_id)) = + current_img.rsplit_once("?vc=") + && let Some(new_base) = r.get(base_path) + { + current_img = format!("{}?vc={}", new_base, vc_id); + *changed = true; + } + } + + let mut is_deleted = false; + if let Some(d) = deletions { + if d.contains(¤t_img) { + is_deleted = true; + } else { + let img_path = Path::new(¤t_img); + for del_path_str in d { + let del_path = Path::new(del_path_str); + if img_path.starts_with(del_path) { + is_deleted = true; + break; + } + + if let Some((base_path, _)) = + current_img.rsplit_once("?vc=") + && base_path == del_path_str + { + is_deleted = true; + break; + } + } + } + } + + if !is_deleted { + new_images.push(current_img); + } else { + *changed = true; + } + } + *images = new_images; + } + AlbumItem::Group { children, .. } => { + process_nodes(children, renames, deletions, folder_rename, changed); + } + } + } + } + + process_nodes(&mut tree, renames, deletions, folder_rename, &mut changed); + + if changed { + let _ = save_albums(tree, app_handle.clone()); + } + } +} + +#[tauri::command] +pub fn get_album_images( + paths: Vec, + app_handle: AppHandle, +) -> Result, String> { + let settings = load_settings(app_handle.clone()).unwrap_or_default(); + let enable_xmp_sync = settings.enable_xmp_sync.unwrap_or(false); + + let result_list: Vec = paths + .into_par_iter() + .filter_map(|virtual_path| { + let (source_path, sidecar_path) = parse_virtual_path(&virtual_path); + if !source_path.exists() { + return None; + } + + let modified = fs::metadata(&source_path) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let is_virtual_copy = virtual_path.contains("?vc="); + + let (is_edited, tags, rating) = { + let mut metadata = crate::exif_processing::load_sidecar(&sidecar_path); + + if enable_xmp_sync + && sync_metadata_from_xmp(&source_path, &mut metadata) + && let Ok(json) = serde_json::to_string_pretty(&metadata) + { + let _ = fs::write(&sidecar_path, json); + } + + let is_raw = crate::formats::is_raw_file(&source_path); + let tm_override = + crate::image_processing::resolve_tonemapper_override(&settings, is_raw); + let edited = crate::image_processing::is_image_edited( + &metadata.adjustments, + is_raw, + tm_override, + ); + (edited, metadata.tags, metadata.rating) + }; + + Some(ImageFile { + path: virtual_path, + modified, + is_edited, + tags, + exif: None, + is_virtual_copy, + rating, + }) + }) + .collect(); + + Ok(result_list) +} diff --git a/src-tauri/src/file_management/folder_tree.rs b/src-tauri/src/file_management/folder_tree.rs new file mode 100644 index 0000000000..fa4f97178e --- /dev/null +++ b/src-tauri/src/file_management/folder_tree.rs @@ -0,0 +1,271 @@ +use super::*; + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct FolderNode { + pub name: String, + pub path: String, + pub children: Vec, + pub is_dir: bool, + pub image_count: usize, + pub has_subdirs: bool, + pub modified: u64, + pub created: u64, +} + +fn has_subdirs(path: &Path) -> bool { + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.filter_map(Result::ok) { + if let Ok(file_type) = entry.file_type() + && file_type.is_dir() + { + let name = entry.file_name(); + if !name.to_string_lossy().starts_with('.') { + return true; + } + } + } + } + false +} + +fn scan_dir_lazy( + path: &Path, + expanded_folders: &HashSet<&str>, + show_image_counts: bool, + prefetch_one_level: bool, +) -> Result<(Vec, usize), std::io::Error> { + let mut children_folders = Vec::new(); + let mut current_dir_image_count = 0; + + let entries = match std::fs::read_dir(path) { + Ok(entries) => entries, + Err(e) => { + log::warn!("Could not scan directory '{}': {}", path.display(), e); + return Ok((Vec::new(), 0)); + } + }; + + for entry in entries.filter_map(Result::ok) { + let current_path = entry.path(); + let (file_type, modified, created) = match entry.metadata() { + Ok(meta) => { + let ft = meta.file_type(); + let mod_time = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH); + let cre_time = meta.created().unwrap_or(mod_time); + + ( + ft, + mod_time + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + cre_time + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + ) + } + Err(_) => continue, + }; + + let file_name = entry.file_name(); + let name_str = file_name.to_string_lossy(); + + if name_str.starts_with('.') { + continue; + } + + if file_type.is_dir() { + let path_str = current_path.to_string_lossy().into_owned(); + let is_expanded = expanded_folders.contains(path_str.as_str()); + + let should_scan = is_expanded || prefetch_one_level; + let next_prefetch = is_expanded; + + let (grand_children, sub_dir_own_images) = if should_scan { + scan_dir_lazy( + ¤t_path, + expanded_folders, + show_image_counts, + next_prefetch, + )? + } else { + let count = if show_image_counts { + WalkDir::new(¤t_path) + .into_iter() + .filter_map(Result::ok) + .filter(|e| { + e.file_type().is_file() + && crate::formats::is_supported_image_file(e.path()) + }) + .count() + } else { + 0 + }; + (Vec::new(), count) + }; + + let has_any_subdirs = if should_scan { + grand_children.iter().any(|c| c.is_dir) + } else { + has_subdirs(¤t_path) + }; + + let grand_children_sum: usize = grand_children.iter().map(|c| c.image_count).sum(); + let total_child_count = sub_dir_own_images + grand_children_sum; + + children_folders.push(FolderNode { + name: name_str.into_owned(), + path: path_str, + children: grand_children, + is_dir: true, + image_count: total_child_count, + has_subdirs: has_any_subdirs, + modified, + created, + }); + } else if show_image_counts + && file_type.is_file() + && crate::formats::is_supported_image_file(¤t_path) + { + current_dir_image_count += 1; + } + } + + children_folders.sort_by_key(|a| a.name.to_lowercase()); + + Ok((children_folders, current_dir_image_count)) +} + +fn get_folder_tree_sync( + path: String, + expanded_folders: Vec, + show_image_counts: bool, +) -> Result { + let root_path = Path::new(&path); + if !root_path.is_dir() { + return Err(format!("Directory does not exist: {}", path)); + } + + let (modified, created) = root_path + .metadata() + .map(|m| { + let mod_time = m.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH); + let cre_time = m.created().unwrap_or(mod_time); + ( + mod_time + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + cre_time + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + ) + }) + .unwrap_or((0, 0)); + + let expanded_set: HashSet<&str> = expanded_folders.iter().map(|s| s.as_str()).collect(); + + let (children, own_count) = scan_dir_lazy(root_path, &expanded_set, show_image_counts, true) + .map_err(|e| e.to_string())?; + + let children_sum: usize = children.iter().map(|c| c.image_count).sum(); + let has_subdirs = children.iter().any(|c| c.is_dir); + + let name = match root_path.file_name() { + Some(n) => n.to_string_lossy().into_owned(), + None => { + let trimmed = path.trim_end_matches(&['/', '\\'][..]); + if trimmed.is_empty() { + path.clone() + } else { + trimmed.to_string() + } + } + }; + + Ok(FolderNode { + name, + path: path.clone(), + children, + is_dir: true, + image_count: own_count + children_sum, + has_subdirs, + modified, + created, + }) +} + +#[tauri::command] +pub async fn get_folder_children( + path: String, + show_image_counts: bool, +) -> Result, String> { + match tauri::async_runtime::spawn_blocking(move || { + let root_path = Path::new(&path); + if !root_path.is_dir() { + return Err(format!("Directory does not exist: {}", path)); + } + let empty_set = HashSet::new(); + let (children, _) = scan_dir_lazy(root_path, &empty_set, show_image_counts, false) + .map_err(|e| e.to_string())?; + + Ok(children) + }) + .await + { + Ok(Ok(children)) => Ok(children), + Ok(Err(e)) => Err(e), + Err(e) => Err(format!("Task failed: {}", e)), + } +} + +#[tauri::command] +pub async fn get_folder_tree( + path: String, + expanded_folders: Vec, + show_image_counts: bool, +) -> Result { + match tauri::async_runtime::spawn_blocking(move || { + get_folder_tree_sync(path, expanded_folders, show_image_counts) + }) + .await + { + Ok(Ok(folder_node)) => Ok(folder_node), + Ok(Err(e)) => Err(e), + Err(e) => Err(format!("Failed to execute folder tree task: {}", e)), + } +} + +#[tauri::command] +pub async fn get_pinned_folder_trees( + paths: Vec, + expanded_folders: Vec, + show_image_counts: bool, +) -> Result, String> { + let result = tauri::async_runtime::spawn_blocking(move || { + let results: Vec> = paths + .par_iter() + .map(|path| { + get_folder_tree_sync(path.clone(), expanded_folders.clone(), show_image_counts) + }) + .collect(); + + let mut folder_nodes = Vec::new(); + for result in results { + match result { + Ok(node) => folder_nodes.push(node), + Err(e) => log::warn!("Failed to get tree for pinned folder: {}", e), + } + } + folder_nodes + }) + .await; + + match result { + Ok(nodes) => Ok(nodes), + Err(e) => Err(format!("Task failed: {}", e)), + } +} diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 6b1cc412c4..ab819b49bf 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -20,6 +20,9 @@ pub use crate::gpu_processing::{ use crate::{AppState, mask_generation::MaskDefinition}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +mod analysis; +pub use analysis::*; + pub trait IntoCowImage<'a> { fn into_cow(self) -> Cow<'a, DynamicImage>; } @@ -2550,713 +2553,3 @@ fn apply_gentle_detail_enhance( }); } -#[derive(Serialize, Clone)] -pub struct HistogramData { - red: Vec, - green: Vec, - blue: Vec, - luma: Vec, -} - -pub fn calculate_histogram_from_image(image: &DynamicImage) -> Result { - let init_hist = || ([0u32; 256], [0u32; 256], [0u32; 256], [0u32; 256]); - - let reduce_hist = |mut a: ([u32; 256], [u32; 256], [u32; 256], [u32; 256]), - b: ([u32; 256], [u32; 256], [u32; 256], [u32; 256])| { - for i in 0..256 { - a.0[i] += b.0[i]; - a.1[i] += b.1[i]; - a.2[i] += b.2[i]; - a.3[i] += b.3[i]; - } - a - }; - - let (r_c, g_c, b_c, l_c) = match image { - DynamicImage::ImageRgb32F(f32_img) => { - let raw = f32_img.as_raw(); - raw.par_chunks(30_000) - .fold(init_hist, |mut acc, chunk| { - for pixel in chunk.chunks_exact(3).step_by(2) { - let r = (pixel[0].clamp(0.0, 1.0) * 255.0) as usize; - let g = (pixel[1].clamp(0.0, 1.0) * 255.0) as usize; - let b = (pixel[2].clamp(0.0, 1.0) * 255.0) as usize; - - acc.0[r] += 1; - acc.1[g] += 1; - acc.2[b] += 1; - - let luma = (r * 218 + g * 732 + b * 74) >> 10; - acc.3[luma.min(255)] += 1; - } - acc - }) - .reduce(init_hist, reduce_hist) - } - _ => { - let rgb = image.to_rgb8(); - let raw = rgb.as_raw(); - raw.par_chunks(30_000) - .fold(init_hist, |mut acc, chunk| { - for pixel in chunk.chunks_exact(3).step_by(2) { - let r = pixel[0] as usize; - let g = pixel[1] as usize; - let b = pixel[2] as usize; - - acc.0[r] += 1; - acc.1[g] += 1; - acc.2[b] += 1; - - let luma = (r * 218 + g * 732 + b * 74) >> 10; - acc.3[luma.min(255)] += 1; - } - acc - }) - .reduce(init_hist, reduce_hist) - } - }; - - let mut red: Vec = r_c.into_iter().map(|c| c as f32).collect(); - let mut green: Vec = g_c.into_iter().map(|c| c as f32).collect(); - let mut blue: Vec = b_c.into_iter().map(|c| c as f32).collect(); - let mut luma: Vec = l_c.into_iter().map(|c| c as f32).collect(); - - let smoothing_sigma = 2.0; - apply_gaussian_smoothing(&mut red, smoothing_sigma); - apply_gaussian_smoothing(&mut green, smoothing_sigma); - apply_gaussian_smoothing(&mut blue, smoothing_sigma); - apply_gaussian_smoothing(&mut luma, smoothing_sigma); - - normalize_histogram_range(&mut red, 0.99); - normalize_histogram_range(&mut green, 0.99); - normalize_histogram_range(&mut blue, 0.99); - normalize_histogram_range(&mut luma, 0.99); - - Ok(HistogramData { - red, - green, - blue, - luma, - }) -} - -fn apply_gaussian_smoothing(histogram: &mut [f32], sigma: f32) { - if sigma <= 0.0 { - return; - } - - let kernel_radius = (sigma * 3.0).ceil() as usize; - if kernel_radius == 0 || kernel_radius >= histogram.len() { - return; - } - - let kernel_size = 2 * kernel_radius + 1; - let mut kernel = vec![0.0; kernel_size]; - let mut kernel_sum = 0.0; - - let two_sigma_sq = 2.0 * sigma * sigma; - for (i, kernel_val) in kernel.iter_mut().enumerate() { - let x = (i as i32 - kernel_radius as i32) as f32; - let val = (-x * x / two_sigma_sq).exp(); - *kernel_val = val; - kernel_sum += val; - } - - if kernel_sum > 0.0 { - for val in &mut kernel { - *val /= kernel_sum; - } - } - - let original = histogram.to_owned(); - let len = histogram.len(); - - for (i, hist_val) in histogram.iter_mut().enumerate() { - let mut smoothed_val = 0.0; - for (k, &kernel_val) in kernel.iter().enumerate() { - let offset = k as i32 - kernel_radius as i32; - let sample_index = i as i32 + offset; - let clamped_index = sample_index.clamp(0, len as i32 - 1) as usize; - smoothed_val += original[clamped_index] * kernel_val; - } - *hist_val = smoothed_val; - } -} - -fn normalize_histogram_range(histogram: &mut [f32], percentile_clip: f32) { - if histogram.is_empty() { - return; - } - - let mut sorted_data = histogram.to_owned(); - sorted_data.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - - let clip_index = ((sorted_data.len() - 1) as f32 * percentile_clip).round() as usize; - let max_val = sorted_data[clip_index.min(sorted_data.len() - 1)]; - - if max_val > 1e-6 { - let scale_factor = 1.0 / max_val; - for value in histogram.iter_mut() { - *value = (*value * scale_factor).min(1.0); - } - } else { - for value in histogram.iter_mut() { - *value = 0.0; - } - } -} - -#[derive(serde::Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct WaveformData { - pub rgb: String, - pub luma: String, - pub parade: String, - pub vectorscope: String, - pub width: u32, - pub height: u32, -} - -pub fn calculate_waveform_from_image( - image: &DynamicImage, - active_channel: Option<&str>, -) -> Result { - const W: usize = 256; - const H: usize = 256; - - let (orig_w, orig_h) = image.dimensions(); - if orig_w == 0 || orig_h == 0 { - return Err("Image has zero dimensions.".to_string()); - } - - let do_rgb = active_channel.is_none() || active_channel == Some("rgb"); - let do_luma = - active_channel.is_none() || active_channel == Some("luma") || active_channel == Some("rgb"); - let do_parade = active_channel.is_none() || active_channel == Some("parade"); - let do_vectorscope = active_channel.is_none() || active_channel == Some("vectorscope"); - - let mut red_bins = if do_rgb { vec![0u32; W * H] } else { vec![] }; - let mut green_bins = if do_rgb { vec![0u32; W * H] } else { vec![] }; - let mut blue_bins = if do_rgb { vec![0u32; W * H] } else { vec![] }; - let mut luma_bins = if do_luma { vec![0u32; W * H] } else { vec![] }; - let mut parade_bins = if do_parade { vec![0u32; W * H] } else { vec![] }; - let mut vector_bins = if do_vectorscope { - vec![0u32; W * H] - } else { - vec![] - }; - - let x_scale = W as f32 / orig_w as f32; - let mut x_buckets = vec![0usize; orig_w as usize]; - - let mut x_buckets_parade_r = vec![0usize; orig_w as usize]; - let mut x_buckets_parade_g = vec![0usize; orig_w as usize]; - let mut x_buckets_parade_b = vec![0usize; orig_w as usize]; - - for x in 0..(orig_w as usize) { - x_buckets[x] = ((x as f32 * x_scale) as usize).min(W - 1); - if do_parade { - let relative_x = x as f32 / orig_w as f32; - x_buckets_parade_r[x] = (relative_x * 82.0) as usize % 82; - x_buckets_parade_g[x] = 87 + (relative_x * 82.0) as usize % 82; - x_buckets_parade_b[x] = 174 + (relative_x * 82.0) as usize % 82; - } - } - - let mut process_pixel = |r: u8, g: u8, b: u8, out_x: usize, orig_x: usize| { - if do_rgb { - red_bins[(255 - r as usize) * W + out_x] += 1; - green_bins[(255 - g as usize) * W + out_x] += 1; - blue_bins[(255 - b as usize) * W + out_x] += 1; - } - if do_luma { - let l = ((r as u32 * 218 + g as u32 * 732 + b as u32 * 74) >> 10).min(255) as usize; - luma_bins[(255 - l) * W + out_x] += 1; - } - if do_parade { - parade_bins[(255 - r as usize) * W + x_buckets_parade_r[orig_x]] += 1; - parade_bins[(255 - g as usize) * W + x_buckets_parade_g[orig_x]] += 1; - parade_bins[(255 - b as usize) * W + x_buckets_parade_b[orig_x]] += 1; - } - if do_vectorscope { - let r_f = r as f32; - let g_f = g as f32; - let b_f = b as f32; - - let mut cb = (-0.1146 * r_f - 0.3854 * g_f + 0.5 * b_f) * 0.836; - let mut cr = (0.5 * r_f - 0.4542 * g_f - 0.0458 * b_f) * 0.836; - - let dist_sq = cb * cb + cr * cr; - if dist_sq > 16129.0 { - let scale = 127.0 / dist_sq.sqrt(); - cb *= scale; - cr *= scale; - } - - let vx = (cb + 128.0).clamp(0.0, 255.0) as usize; - let vy = (128.0 - cr).clamp(0.0, 255.0) as usize; - vector_bins[vy * W + vx] += 1; - } - }; - - match image { - DynamicImage::ImageRgb32F(f32_img) => { - let raw = f32_img.as_raw(); - let stride = orig_w as usize * 3; - for y in 0..(orig_h as usize) { - let row = y * stride; - for (x, &x_bucket) in x_buckets.iter().enumerate() { - let i = row + x * 3; - process_pixel( - (raw[i].clamp(0.0, 1.0) * 255.0) as u8, - (raw[i + 1].clamp(0.0, 1.0) * 255.0) as u8, - (raw[i + 2].clamp(0.0, 1.0) * 255.0) as u8, - x_bucket, - x, - ); - } - } - } - _ => { - let rgb = image.to_rgb8(); - let raw = rgb.as_raw(); - let stride = orig_w as usize * 3; - for y in 0..(orig_h as usize) { - let row = y * stride; - for (x, &x_bucket) in x_buckets.iter().enumerate() { - let i = row + x * 3; - process_pixel(raw[i], raw[i + 1], raw[i + 2], x_bucket, x); - } - } - } - } - - let build_lut = |bins: &[u32], do_calc: bool| -> (Vec, u32) { - if !do_calc { - return (vec![0; 1], 0); - } - let max_val = *bins.iter().max().unwrap_or(&0); - if max_val == 0 { - return (vec![0; 1], 0); - } - let scale = 255.0 / (1.0 + max_val as f32).ln(); - let lut = (0..=max_val) - .map(|v| { - if v == 0 { - 0 - } else { - ((1.0 + v as f32).ln() * scale) as u8 - } - }) - .collect(); - (lut, max_val) - }; - - let (lut_r, max_r) = build_lut(&red_bins, do_rgb); - let (lut_g, max_g) = build_lut(&green_bins, do_rgb); - let (lut_b, max_b) = build_lut(&blue_bins, do_rgb); - let (lut_l, max_l) = build_lut(&luma_bins, do_luma); - let (lut_p, max_p) = build_lut(¶de_bins, do_parade); - let (lut_v, max_v) = build_lut(&vector_bins, do_vectorscope); - - let pixel_count = W * H; - let byte_count = pixel_count * 4; - - let mut rgba_rgb = if do_rgb { - vec![0u8; byte_count] - } else { - vec![] - }; - let mut rgba_luma = if do_luma { - vec![0u8; byte_count] - } else { - vec![] - }; - let mut rgba_parade = if do_parade { - vec![0u8; byte_count] - } else { - vec![] - }; - let mut rgba_vector = if do_vectorscope { - vec![0u8; byte_count] - } else { - vec![] - }; - - for i in 0..pixel_count { - let x = i % W; - let y = i / W; - let off = i * 4; - - if do_rgb { - let r = if red_bins[i] <= max_r { - lut_r[red_bins[i] as usize] - } else { - 0 - }; - let g = if green_bins[i] <= max_g { - lut_g[green_bins[i] as usize] - } else { - 0 - }; - let b = if blue_bins[i] <= max_b { - lut_b[blue_bins[i] as usize] - } else { - 0 - }; - if r > 0 || g > 0 || b > 0 { - rgba_rgb[off] = r; - rgba_rgb[off + 1] = g; - rgba_rgb[off + 2] = b; - rgba_rgb[off + 3] = r.max(g).max(b); - } - } - - if do_luma && luma_bins[i] > 0 && luma_bins[i] <= max_l { - let l = lut_l[luma_bins[i] as usize]; - rgba_luma[off] = 255; - rgba_luma[off + 1] = 255; - rgba_luma[off + 2] = 255; - rgba_luma[off + 3] = l; - } - - if do_parade && parade_bins[i] > 0 && parade_bins[i] <= max_p { - let bright = lut_p[parade_bins[i] as usize]; - if x < 82 { - rgba_parade[off] = 255; - rgba_parade[off + 3] = bright; - } else if (87..169).contains(&x) { - rgba_parade[off + 1] = 255; - rgba_parade[off + 3] = bright; - } else if x >= 174 { - rgba_parade[off + 2] = 255; - rgba_parade[off + 3] = bright; - } - } - - if do_vectorscope { - let val = vector_bins[i]; - - let dx = x as f32 - 128.0; - let dy = 128.0 - y as f32; - let min_d = dx.abs().min(dy.abs()); - let dist = (dx * dx + dy * dy).sqrt(); - - if val > 0 && val <= max_v { - let bright = lut_v[val as usize]; - - let y_mid = 128.0; - rgba_vector[off] = (y_mid + 1.402 * (dy / 0.836)).clamp(0.0, 255.0) as u8; - rgba_vector[off + 1] = (y_mid - 0.344136 * (dx / 0.836) - 0.714136 * (dy / 0.836)) - .clamp(0.0, 255.0) as u8; - rgba_vector[off + 2] = (y_mid + 1.772 * (dx / 0.836)).clamp(0.0, 255.0) as u8; - rgba_vector[off + 3] = bright; - } else if min_d <= 1.0 { - let alpha = (40.0 - min_d * 30.0).clamp(0.0, 255.0) as u8; - rgba_vector[off] = 255; - rgba_vector[off + 1] = 255; - rgba_vector[off + 2] = 255; - rgba_vector[off + 3] = alpha; - } else if (dist - 127.0).abs() < 0.8 || (dist - 64.0).abs() < 0.8 { - rgba_vector[off] = 255; - rgba_vector[off + 1] = 255; - rgba_vector[off + 2] = 255; - rgba_vector[off + 3] = 15; - } else if dx < 0.0 && dy > 0.0 && (dy + 1.53 * dx).abs() < 1.0 { - rgba_vector[off] = 255; - rgba_vector[off + 1] = 200; - rgba_vector[off + 2] = 150; - rgba_vector[off + 3] = 120; - } - } - } - - Ok(WaveformData { - rgb: if do_rgb { - BASE64.encode(&rgba_rgb) - } else { - String::new() - }, - luma: if do_luma { - BASE64.encode(&rgba_luma) - } else { - String::new() - }, - parade: if do_parade { - BASE64.encode(&rgba_parade) - } else { - String::new() - }, - vectorscope: if do_vectorscope { - BASE64.encode(&rgba_vector) - } else { - String::new() - }, - width: W as u32, - height: H as u32, - }) -} - -pub fn perform_auto_analysis(image: &DynamicImage) -> AutoAdjustmentResults { - const ANALYSIS_MAX_DIM: u32 = 1024; - - const LUMA_R: f32 = 0.2126; - const LUMA_G: f32 = 0.7152; - const LUMA_B: f32 = 0.0722; - - const EXPOSURE_MIDPOINT: f64 = 128.0; - const EXPOSURE_SCALE: f64 = 0.125; - const WHITE_POINT_HARD_LIMIT: usize = 245; - const HIGHLIGHT_LUMA_THRESHOLD: usize = 240; - const CLIPPED_LUMA_THRESHOLD: usize = 250; - const HIGHLIGHT_PERCENT_THRESHOLD: f64 = 0.02; - const CLIPPED_PERCENT_THRESHOLD: f64 = 0.005; - const EXPOSURE_CEILING: f64 = 250.0; - - const TARGET_RANGE: f64 = 220.0; - const CONTRAST_SCALE: f64 = 10.0; - const HIGHLIGHT_CONTRAST_REDUCE: f64 = 0.5; - - const SHADOW_LUMA_MAX: usize = 32; - const SHADOW_PERCENT_THRESHOLD: f64 = 0.05; - const SHADOW_BOOST_SCALE: f64 = 40.0; - const SHADOW_MAX: f64 = 50.0; - const HIGHLIGHT_BOOST_SCALE: f64 = 120.0; - const HIGHLIGHT_MAX: f64 = 70.0; - - const VIBRANCY_SAT_THRESHOLD: f32 = 0.2; - const VIBRANCY_SCALE: f64 = 120.0; - - const DEHAZE_RANGE_THRESHOLD: f64 = 120.0; - const DEHAZE_SAT_THRESHOLD: f32 = 0.15; - const DEHAZE_SCALE: f64 = 35.0; - const CLARITY_RANGE_THRESHOLD: f64 = 180.0; - const CLARITY_SCALE: f64 = 50.0; - - const VIGNETTE_CENTER_LOW: f32 = 0.25; - const VIGNETTE_CENTER_HIGH: f32 = 0.75; - - const VIGNETTE_SCALE: f64 = 100.0; - const VIGNETTE_CENTRE_DIFF_THRESHOLD: f32 = 0.05; - const CENTRE_SCALE: f64 = 100.0; - const CENTRE_MAX: f64 = 60.0; - - const MID_GRAY: f64 = 128.0; - const BLACKS_SCALE: f64 = 0.5; - const WHITES_SCALE: f64 = 0.2; - const EXPOSURE_OUTPUT_SCALE: f64 = 20.0; - const BRIGHTNESS_SCALE: f64 = 0.007; - - let analysis_preview = downscale_f32_image(image, ANALYSIS_MAX_DIM, ANALYSIS_MAX_DIM); - let rgb_image = analysis_preview.to_rgb8(); - let total_pixels = (rgb_image.width() * rgb_image.height()) as f64; - - let (width, height) = rgb_image.dimensions(); - let cx0 = (width as f32 * VIGNETTE_CENTER_LOW) as u32; - let cx1 = (width as f32 * VIGNETTE_CENTER_HIGH) as u32; - let cy0 = (height as f32 * VIGNETTE_CENTER_LOW) as u32; - let cy1 = (height as f32 * VIGNETTE_CENTER_HIGH) as u32; - - let mut luma_hist = vec![0u32; 256]; - let mut mean_saturation = 0.0f32; - let mut center_sum = 0.0f32; - let mut edge_sum = 0.0f32; - let mut center_n = 0u32; - let mut edge_n = 0u32; - - for (x, y, pixel) in rgb_image.enumerate_pixels() { - let r = pixel[0] as f32; - let g = pixel[1] as f32; - let b = pixel[2] as f32; - - let luma_f = LUMA_R * r + LUMA_G * g + LUMA_B * b; - luma_hist[(luma_f.round() as usize).min(255)] += 1; - - let r_n = r / 255.0; - let g_n = g / 255.0; - let b_n = b / 255.0; - let max_c = r_n.max(g_n).max(b_n); - let min_c = r_n.min(g_n).min(b_n); - if max_c > 0.0 { - let s = (max_c - min_c) / max_c; - mean_saturation += s; - } - - let luma_norm = luma_f / 255.0; - if x >= cx0 && x < cx1 && y >= cy0 && y < cy1 { - center_sum += luma_norm; - center_n += 1; - } else { - edge_sum += luma_norm; - edge_n += 1; - } - } - - mean_saturation /= total_pixels as f32; - - let percentile = |hist: &Vec, p: f64| -> usize { - let target = (total_pixels * p) as u32; - let mut cumulative = 0u32; - for (i, &v) in hist.iter().enumerate() { - cumulative += v; - if cumulative >= target { - return i; - } - } - 255 - }; - - let p1 = percentile(&luma_hist, 0.01); - let p50 = percentile(&luma_hist, 0.50); - let p99 = percentile(&luma_hist, 0.99); - - let black_point = p1; - let white_point = p99; - let range = (white_point as f64 - black_point as f64).max(1.0); - - let highlight_percent = - luma_hist[HIGHLIGHT_LUMA_THRESHOLD..256].iter().sum::() as f64 / total_pixels; - let clipped_percent = - luma_hist[CLIPPED_LUMA_THRESHOLD..256].iter().sum::() as f64 / total_pixels; - - let mut exposure = (EXPOSURE_MIDPOINT - p50 as f64) * EXPOSURE_SCALE; - - if white_point > WHITE_POINT_HARD_LIMIT - || highlight_percent > HIGHLIGHT_PERCENT_THRESHOLD - || clipped_percent > CLIPPED_PERCENT_THRESHOLD - { - exposure = exposure.min(0.0); - } - - if white_point as f64 + exposure > EXPOSURE_CEILING { - exposure = EXPOSURE_CEILING - white_point as f64; - } - - let mut contrast = 0.0f64; - if range < TARGET_RANGE { - contrast = ((TARGET_RANGE / range) - 1.0) * CONTRAST_SCALE; - } - if highlight_percent > HIGHLIGHT_PERCENT_THRESHOLD { - contrast *= HIGHLIGHT_CONTRAST_REDUCE; - } - - let shadow_percent = luma_hist[0..SHADOW_LUMA_MAX].iter().sum::() as f64 / total_pixels; - - let mut shadows = 0.0f64; - if shadow_percent > SHADOW_PERCENT_THRESHOLD { - shadows = (shadow_percent * SHADOW_BOOST_SCALE).min(SHADOW_MAX); - } - - let mut highlights = 0.0f64; - if highlight_percent > HIGHLIGHT_PERCENT_THRESHOLD { - highlights = -(highlight_percent * HIGHLIGHT_BOOST_SCALE).min(HIGHLIGHT_MAX); - } - - let mut vibrancy = 0.0f64; - if mean_saturation < VIBRANCY_SAT_THRESHOLD { - vibrancy = (VIBRANCY_SAT_THRESHOLD - mean_saturation) as f64 * VIBRANCY_SCALE; - } - - let mut dehaze = 0.0f64; - if range < DEHAZE_RANGE_THRESHOLD && mean_saturation < DEHAZE_SAT_THRESHOLD { - dehaze = (1.0 - range / DEHAZE_RANGE_THRESHOLD) * DEHAZE_SCALE; - } - - let mut clarity = 0.0f64; - if range < CLARITY_RANGE_THRESHOLD { - clarity = (1.0 - range / CLARITY_RANGE_THRESHOLD) * CLARITY_SCALE; - } - - let mut vignette_amount = 0.0f64; - let mut centre = 0.0f64; - - if center_n > 0 && edge_n > 0 { - let c_avg = center_sum / center_n as f32; - let e_avg = edge_sum / edge_n as f32; - - if e_avg < c_avg { - let diff = c_avg - e_avg; - vignette_amount = -(diff as f64 * VIGNETTE_SCALE); - - if diff > VIGNETTE_CENTRE_DIFF_THRESHOLD { - centre = (diff as f64 * CENTRE_SCALE).min(CENTRE_MAX); - } - } - } - - let mut adjusted_luma_hist = vec![0u32; 256]; - for pixel in rgb_image.pixels() { - let r = pixel[0] as f64; - let g = pixel[1] as f64; - let b = pixel[2] as f64; - let mut luma = LUMA_R as f64 * r + LUMA_G as f64 * g + LUMA_B as f64 * b; - luma += exposure; - luma = (luma - MID_GRAY) * (1.0 + contrast / 100.0) + MID_GRAY; - adjusted_luma_hist[luma.clamp(0.0, 255.0).round() as usize] += 1; - } - - let adj_p1 = percentile(&adjusted_luma_hist, 0.01); - let adj_p50 = percentile(&adjusted_luma_hist, 0.50); - let adj_p99 = percentile(&adjusted_luma_hist, 0.99); - let blacks: f64 = -(adj_p1 as f64 * BLACKS_SCALE); - let whites: f64 = (adj_p99 as f64 - 255.0) * WHITES_SCALE; - let brightness: f64 = (MID_GRAY - adj_p50 as f64) * BRIGHTNESS_SCALE; - - AutoAdjustmentResults { - exposure: (exposure / EXPOSURE_OUTPUT_SCALE).clamp(-5.0, 5.0), - brightness: brightness.clamp(-5.0, 5.0), - contrast: contrast.clamp(-100.0, 100.0), - highlights: highlights.clamp(-100.0, 100.0), - shadows: shadows.clamp(-100.0, 100.0), - vibrancy: vibrancy.clamp(-100.0, 100.0), - vignette_amount: vignette_amount.clamp(-100.0, 100.0), - temperature: 0.0, - tint: 0.0, - dehaze: dehaze.clamp(-100.0, 100.0), - clarity: clarity.clamp(-100.0, 100.0), - centre: centre.clamp(-100.0, 100.0), - whites: whites.clamp(-100.0, 100.0), - blacks: blacks.clamp(-100.0, 100.0), - } -} - -pub fn auto_results_to_json(results: &AutoAdjustmentResults) -> serde_json::Value { - json!({ - "exposure": results.exposure, - "brightness": results.brightness, - "contrast": results.contrast, - "highlights": results.highlights, - "shadows": results.shadows, - "vibrance": results.vibrancy, - "vignetteAmount": results.vignette_amount, - "clarity": results.clarity, - "centré": results.centre, - - "dehaze": results.dehaze, - "sectionVisibility": { - "basic": true, - "color": true, - "effects": true - }, - "whites": results.whites, - "blacks": results.blacks - }) -} - -#[tauri::command] -pub fn calculate_auto_adjustments( - state: tauri::State, -) -> Result { - let original_image = state - .original_image - .lock() - .unwrap() - .as_ref() - .ok_or("No image loaded for auto adjustments")? - .image - .clone(); - - let results = perform_auto_analysis(&original_image); - - Ok(auto_results_to_json(&results)) -} diff --git a/src-tauri/src/image_processing/analysis.rs b/src-tauri/src/image_processing/analysis.rs new file mode 100644 index 0000000000..cc1ee4d233 --- /dev/null +++ b/src-tauri/src/image_processing/analysis.rs @@ -0,0 +1,712 @@ +use super::*; + +#[derive(Serialize, Clone)] +pub struct HistogramData { + red: Vec, + green: Vec, + blue: Vec, + luma: Vec, +} + +pub fn calculate_histogram_from_image(image: &DynamicImage) -> Result { + let init_hist = || ([0u32; 256], [0u32; 256], [0u32; 256], [0u32; 256]); + + let reduce_hist = |mut a: ([u32; 256], [u32; 256], [u32; 256], [u32; 256]), + b: ([u32; 256], [u32; 256], [u32; 256], [u32; 256])| { + for i in 0..256 { + a.0[i] += b.0[i]; + a.1[i] += b.1[i]; + a.2[i] += b.2[i]; + a.3[i] += b.3[i]; + } + a + }; + + let (r_c, g_c, b_c, l_c) = match image { + DynamicImage::ImageRgb32F(f32_img) => { + let raw = f32_img.as_raw(); + raw.par_chunks(30_000) + .fold(init_hist, |mut acc, chunk| { + for pixel in chunk.chunks_exact(3).step_by(2) { + let r = (pixel[0].clamp(0.0, 1.0) * 255.0) as usize; + let g = (pixel[1].clamp(0.0, 1.0) * 255.0) as usize; + let b = (pixel[2].clamp(0.0, 1.0) * 255.0) as usize; + + acc.0[r] += 1; + acc.1[g] += 1; + acc.2[b] += 1; + + let luma = (r * 218 + g * 732 + b * 74) >> 10; + acc.3[luma.min(255)] += 1; + } + acc + }) + .reduce(init_hist, reduce_hist) + } + _ => { + let rgb = image.to_rgb8(); + let raw = rgb.as_raw(); + raw.par_chunks(30_000) + .fold(init_hist, |mut acc, chunk| { + for pixel in chunk.chunks_exact(3).step_by(2) { + let r = pixel[0] as usize; + let g = pixel[1] as usize; + let b = pixel[2] as usize; + + acc.0[r] += 1; + acc.1[g] += 1; + acc.2[b] += 1; + + let luma = (r * 218 + g * 732 + b * 74) >> 10; + acc.3[luma.min(255)] += 1; + } + acc + }) + .reduce(init_hist, reduce_hist) + } + }; + + let mut red: Vec = r_c.into_iter().map(|c| c as f32).collect(); + let mut green: Vec = g_c.into_iter().map(|c| c as f32).collect(); + let mut blue: Vec = b_c.into_iter().map(|c| c as f32).collect(); + let mut luma: Vec = l_c.into_iter().map(|c| c as f32).collect(); + + let smoothing_sigma = 2.0; + apply_gaussian_smoothing(&mut red, smoothing_sigma); + apply_gaussian_smoothing(&mut green, smoothing_sigma); + apply_gaussian_smoothing(&mut blue, smoothing_sigma); + apply_gaussian_smoothing(&mut luma, smoothing_sigma); + + normalize_histogram_range(&mut red, 0.99); + normalize_histogram_range(&mut green, 0.99); + normalize_histogram_range(&mut blue, 0.99); + normalize_histogram_range(&mut luma, 0.99); + + Ok(HistogramData { + red, + green, + blue, + luma, + }) +} + +fn apply_gaussian_smoothing(histogram: &mut [f32], sigma: f32) { + if sigma <= 0.0 { + return; + } + + let kernel_radius = (sigma * 3.0).ceil() as usize; + if kernel_radius == 0 || kernel_radius >= histogram.len() { + return; + } + + let kernel_size = 2 * kernel_radius + 1; + let mut kernel = vec![0.0; kernel_size]; + let mut kernel_sum = 0.0; + + let two_sigma_sq = 2.0 * sigma * sigma; + for (i, kernel_val) in kernel.iter_mut().enumerate() { + let x = (i as i32 - kernel_radius as i32) as f32; + let val = (-x * x / two_sigma_sq).exp(); + *kernel_val = val; + kernel_sum += val; + } + + if kernel_sum > 0.0 { + for val in &mut kernel { + *val /= kernel_sum; + } + } + + let original = histogram.to_owned(); + let len = histogram.len(); + + for (i, hist_val) in histogram.iter_mut().enumerate() { + let mut smoothed_val = 0.0; + for (k, &kernel_val) in kernel.iter().enumerate() { + let offset = k as i32 - kernel_radius as i32; + let sample_index = i as i32 + offset; + let clamped_index = sample_index.clamp(0, len as i32 - 1) as usize; + smoothed_val += original[clamped_index] * kernel_val; + } + *hist_val = smoothed_val; + } +} + +fn normalize_histogram_range(histogram: &mut [f32], percentile_clip: f32) { + if histogram.is_empty() { + return; + } + + let mut sorted_data = histogram.to_owned(); + sorted_data.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + let clip_index = ((sorted_data.len() - 1) as f32 * percentile_clip).round() as usize; + let max_val = sorted_data[clip_index.min(sorted_data.len() - 1)]; + + if max_val > 1e-6 { + let scale_factor = 1.0 / max_val; + for value in histogram.iter_mut() { + *value = (*value * scale_factor).min(1.0); + } + } else { + for value in histogram.iter_mut() { + *value = 0.0; + } + } +} + +#[derive(serde::Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WaveformData { + pub rgb: String, + pub luma: String, + pub parade: String, + pub vectorscope: String, + pub width: u32, + pub height: u32, +} + +pub fn calculate_waveform_from_image( + image: &DynamicImage, + active_channel: Option<&str>, +) -> Result { + const W: usize = 256; + const H: usize = 256; + + let (orig_w, orig_h) = image.dimensions(); + if orig_w == 0 || orig_h == 0 { + return Err("Image has zero dimensions.".to_string()); + } + + let do_rgb = active_channel.is_none() || active_channel == Some("rgb"); + let do_luma = + active_channel.is_none() || active_channel == Some("luma") || active_channel == Some("rgb"); + let do_parade = active_channel.is_none() || active_channel == Some("parade"); + let do_vectorscope = active_channel.is_none() || active_channel == Some("vectorscope"); + + let mut red_bins = if do_rgb { vec![0u32; W * H] } else { vec![] }; + let mut green_bins = if do_rgb { vec![0u32; W * H] } else { vec![] }; + let mut blue_bins = if do_rgb { vec![0u32; W * H] } else { vec![] }; + let mut luma_bins = if do_luma { vec![0u32; W * H] } else { vec![] }; + let mut parade_bins = if do_parade { vec![0u32; W * H] } else { vec![] }; + let mut vector_bins = if do_vectorscope { + vec![0u32; W * H] + } else { + vec![] + }; + + let x_scale = W as f32 / orig_w as f32; + let mut x_buckets = vec![0usize; orig_w as usize]; + + let mut x_buckets_parade_r = vec![0usize; orig_w as usize]; + let mut x_buckets_parade_g = vec![0usize; orig_w as usize]; + let mut x_buckets_parade_b = vec![0usize; orig_w as usize]; + + for x in 0..(orig_w as usize) { + x_buckets[x] = ((x as f32 * x_scale) as usize).min(W - 1); + if do_parade { + let relative_x = x as f32 / orig_w as f32; + x_buckets_parade_r[x] = (relative_x * 82.0) as usize % 82; + x_buckets_parade_g[x] = 87 + (relative_x * 82.0) as usize % 82; + x_buckets_parade_b[x] = 174 + (relative_x * 82.0) as usize % 82; + } + } + + let mut process_pixel = |r: u8, g: u8, b: u8, out_x: usize, orig_x: usize| { + if do_rgb { + red_bins[(255 - r as usize) * W + out_x] += 1; + green_bins[(255 - g as usize) * W + out_x] += 1; + blue_bins[(255 - b as usize) * W + out_x] += 1; + } + if do_luma { + let l = ((r as u32 * 218 + g as u32 * 732 + b as u32 * 74) >> 10).min(255) as usize; + luma_bins[(255 - l) * W + out_x] += 1; + } + if do_parade { + parade_bins[(255 - r as usize) * W + x_buckets_parade_r[orig_x]] += 1; + parade_bins[(255 - g as usize) * W + x_buckets_parade_g[orig_x]] += 1; + parade_bins[(255 - b as usize) * W + x_buckets_parade_b[orig_x]] += 1; + } + if do_vectorscope { + let r_f = r as f32; + let g_f = g as f32; + let b_f = b as f32; + + let mut cb = (-0.1146 * r_f - 0.3854 * g_f + 0.5 * b_f) * 0.836; + let mut cr = (0.5 * r_f - 0.4542 * g_f - 0.0458 * b_f) * 0.836; + + let dist_sq = cb * cb + cr * cr; + if dist_sq > 16129.0 { + let scale = 127.0 / dist_sq.sqrt(); + cb *= scale; + cr *= scale; + } + + let vx = (cb + 128.0).clamp(0.0, 255.0) as usize; + let vy = (128.0 - cr).clamp(0.0, 255.0) as usize; + vector_bins[vy * W + vx] += 1; + } + }; + + match image { + DynamicImage::ImageRgb32F(f32_img) => { + let raw = f32_img.as_raw(); + let stride = orig_w as usize * 3; + for y in 0..(orig_h as usize) { + let row = y * stride; + for (x, &x_bucket) in x_buckets.iter().enumerate() { + let i = row + x * 3; + process_pixel( + (raw[i].clamp(0.0, 1.0) * 255.0) as u8, + (raw[i + 1].clamp(0.0, 1.0) * 255.0) as u8, + (raw[i + 2].clamp(0.0, 1.0) * 255.0) as u8, + x_bucket, + x, + ); + } + } + } + _ => { + let rgb = image.to_rgb8(); + let raw = rgb.as_raw(); + let stride = orig_w as usize * 3; + for y in 0..(orig_h as usize) { + let row = y * stride; + for (x, &x_bucket) in x_buckets.iter().enumerate() { + let i = row + x * 3; + process_pixel(raw[i], raw[i + 1], raw[i + 2], x_bucket, x); + } + } + } + } + + let build_lut = |bins: &[u32], do_calc: bool| -> (Vec, u32) { + if !do_calc { + return (vec![0; 1], 0); + } + let max_val = *bins.iter().max().unwrap_or(&0); + if max_val == 0 { + return (vec![0; 1], 0); + } + let scale = 255.0 / (1.0 + max_val as f32).ln(); + let lut = (0..=max_val) + .map(|v| { + if v == 0 { + 0 + } else { + ((1.0 + v as f32).ln() * scale) as u8 + } + }) + .collect(); + (lut, max_val) + }; + + let (lut_r, max_r) = build_lut(&red_bins, do_rgb); + let (lut_g, max_g) = build_lut(&green_bins, do_rgb); + let (lut_b, max_b) = build_lut(&blue_bins, do_rgb); + let (lut_l, max_l) = build_lut(&luma_bins, do_luma); + let (lut_p, max_p) = build_lut(¶de_bins, do_parade); + let (lut_v, max_v) = build_lut(&vector_bins, do_vectorscope); + + let pixel_count = W * H; + let byte_count = pixel_count * 4; + + let mut rgba_rgb = if do_rgb { + vec![0u8; byte_count] + } else { + vec![] + }; + let mut rgba_luma = if do_luma { + vec![0u8; byte_count] + } else { + vec![] + }; + let mut rgba_parade = if do_parade { + vec![0u8; byte_count] + } else { + vec![] + }; + let mut rgba_vector = if do_vectorscope { + vec![0u8; byte_count] + } else { + vec![] + }; + + for i in 0..pixel_count { + let x = i % W; + let y = i / W; + let off = i * 4; + + if do_rgb { + let r = if red_bins[i] <= max_r { + lut_r[red_bins[i] as usize] + } else { + 0 + }; + let g = if green_bins[i] <= max_g { + lut_g[green_bins[i] as usize] + } else { + 0 + }; + let b = if blue_bins[i] <= max_b { + lut_b[blue_bins[i] as usize] + } else { + 0 + }; + if r > 0 || g > 0 || b > 0 { + rgba_rgb[off] = r; + rgba_rgb[off + 1] = g; + rgba_rgb[off + 2] = b; + rgba_rgb[off + 3] = r.max(g).max(b); + } + } + + if do_luma && luma_bins[i] > 0 && luma_bins[i] <= max_l { + let l = lut_l[luma_bins[i] as usize]; + rgba_luma[off] = 255; + rgba_luma[off + 1] = 255; + rgba_luma[off + 2] = 255; + rgba_luma[off + 3] = l; + } + + if do_parade && parade_bins[i] > 0 && parade_bins[i] <= max_p { + let bright = lut_p[parade_bins[i] as usize]; + if x < 82 { + rgba_parade[off] = 255; + rgba_parade[off + 3] = bright; + } else if (87..169).contains(&x) { + rgba_parade[off + 1] = 255; + rgba_parade[off + 3] = bright; + } else if x >= 174 { + rgba_parade[off + 2] = 255; + rgba_parade[off + 3] = bright; + } + } + + if do_vectorscope { + let val = vector_bins[i]; + + let dx = x as f32 - 128.0; + let dy = 128.0 - y as f32; + let min_d = dx.abs().min(dy.abs()); + let dist = (dx * dx + dy * dy).sqrt(); + + if val > 0 && val <= max_v { + let bright = lut_v[val as usize]; + + let y_mid = 128.0; + rgba_vector[off] = (y_mid + 1.402 * (dy / 0.836)).clamp(0.0, 255.0) as u8; + rgba_vector[off + 1] = (y_mid - 0.344136 * (dx / 0.836) - 0.714136 * (dy / 0.836)) + .clamp(0.0, 255.0) as u8; + rgba_vector[off + 2] = (y_mid + 1.772 * (dx / 0.836)).clamp(0.0, 255.0) as u8; + rgba_vector[off + 3] = bright; + } else if min_d <= 1.0 { + let alpha = (40.0 - min_d * 30.0).clamp(0.0, 255.0) as u8; + rgba_vector[off] = 255; + rgba_vector[off + 1] = 255; + rgba_vector[off + 2] = 255; + rgba_vector[off + 3] = alpha; + } else if (dist - 127.0).abs() < 0.8 || (dist - 64.0).abs() < 0.8 { + rgba_vector[off] = 255; + rgba_vector[off + 1] = 255; + rgba_vector[off + 2] = 255; + rgba_vector[off + 3] = 15; + } else if dx < 0.0 && dy > 0.0 && (dy + 1.53 * dx).abs() < 1.0 { + rgba_vector[off] = 255; + rgba_vector[off + 1] = 200; + rgba_vector[off + 2] = 150; + rgba_vector[off + 3] = 120; + } + } + } + + Ok(WaveformData { + rgb: if do_rgb { + BASE64.encode(&rgba_rgb) + } else { + String::new() + }, + luma: if do_luma { + BASE64.encode(&rgba_luma) + } else { + String::new() + }, + parade: if do_parade { + BASE64.encode(&rgba_parade) + } else { + String::new() + }, + vectorscope: if do_vectorscope { + BASE64.encode(&rgba_vector) + } else { + String::new() + }, + width: W as u32, + height: H as u32, + }) +} + +pub fn perform_auto_analysis(image: &DynamicImage) -> AutoAdjustmentResults { + const ANALYSIS_MAX_DIM: u32 = 1024; + + const LUMA_R: f32 = 0.2126; + const LUMA_G: f32 = 0.7152; + const LUMA_B: f32 = 0.0722; + + const EXPOSURE_MIDPOINT: f64 = 128.0; + const EXPOSURE_SCALE: f64 = 0.125; + const WHITE_POINT_HARD_LIMIT: usize = 245; + const HIGHLIGHT_LUMA_THRESHOLD: usize = 240; + const CLIPPED_LUMA_THRESHOLD: usize = 250; + const HIGHLIGHT_PERCENT_THRESHOLD: f64 = 0.02; + const CLIPPED_PERCENT_THRESHOLD: f64 = 0.005; + const EXPOSURE_CEILING: f64 = 250.0; + + const TARGET_RANGE: f64 = 220.0; + const CONTRAST_SCALE: f64 = 10.0; + const HIGHLIGHT_CONTRAST_REDUCE: f64 = 0.5; + + const SHADOW_LUMA_MAX: usize = 32; + const SHADOW_PERCENT_THRESHOLD: f64 = 0.05; + const SHADOW_BOOST_SCALE: f64 = 40.0; + const SHADOW_MAX: f64 = 50.0; + const HIGHLIGHT_BOOST_SCALE: f64 = 120.0; + const HIGHLIGHT_MAX: f64 = 70.0; + + const VIBRANCY_SAT_THRESHOLD: f32 = 0.2; + const VIBRANCY_SCALE: f64 = 120.0; + + const DEHAZE_RANGE_THRESHOLD: f64 = 120.0; + const DEHAZE_SAT_THRESHOLD: f32 = 0.15; + const DEHAZE_SCALE: f64 = 35.0; + const CLARITY_RANGE_THRESHOLD: f64 = 180.0; + const CLARITY_SCALE: f64 = 50.0; + + const VIGNETTE_CENTER_LOW: f32 = 0.25; + const VIGNETTE_CENTER_HIGH: f32 = 0.75; + + const VIGNETTE_SCALE: f64 = 100.0; + const VIGNETTE_CENTRE_DIFF_THRESHOLD: f32 = 0.05; + const CENTRE_SCALE: f64 = 100.0; + const CENTRE_MAX: f64 = 60.0; + + const MID_GRAY: f64 = 128.0; + const BLACKS_SCALE: f64 = 0.5; + const WHITES_SCALE: f64 = 0.2; + const EXPOSURE_OUTPUT_SCALE: f64 = 20.0; + const BRIGHTNESS_SCALE: f64 = 0.007; + + let analysis_preview = downscale_f32_image(image, ANALYSIS_MAX_DIM, ANALYSIS_MAX_DIM); + let rgb_image = analysis_preview.to_rgb8(); + let total_pixels = (rgb_image.width() * rgb_image.height()) as f64; + + let (width, height) = rgb_image.dimensions(); + let cx0 = (width as f32 * VIGNETTE_CENTER_LOW) as u32; + let cx1 = (width as f32 * VIGNETTE_CENTER_HIGH) as u32; + let cy0 = (height as f32 * VIGNETTE_CENTER_LOW) as u32; + let cy1 = (height as f32 * VIGNETTE_CENTER_HIGH) as u32; + + let mut luma_hist = vec![0u32; 256]; + let mut mean_saturation = 0.0f32; + let mut center_sum = 0.0f32; + let mut edge_sum = 0.0f32; + let mut center_n = 0u32; + let mut edge_n = 0u32; + + for (x, y, pixel) in rgb_image.enumerate_pixels() { + let r = pixel[0] as f32; + let g = pixel[1] as f32; + let b = pixel[2] as f32; + + let luma_f = LUMA_R * r + LUMA_G * g + LUMA_B * b; + luma_hist[(luma_f.round() as usize).min(255)] += 1; + + let r_n = r / 255.0; + let g_n = g / 255.0; + let b_n = b / 255.0; + let max_c = r_n.max(g_n).max(b_n); + let min_c = r_n.min(g_n).min(b_n); + if max_c > 0.0 { + let s = (max_c - min_c) / max_c; + mean_saturation += s; + } + + let luma_norm = luma_f / 255.0; + if x >= cx0 && x < cx1 && y >= cy0 && y < cy1 { + center_sum += luma_norm; + center_n += 1; + } else { + edge_sum += luma_norm; + edge_n += 1; + } + } + + mean_saturation /= total_pixels as f32; + + let percentile = |hist: &Vec, p: f64| -> usize { + let target = (total_pixels * p) as u32; + let mut cumulative = 0u32; + for (i, &v) in hist.iter().enumerate() { + cumulative += v; + if cumulative >= target { + return i; + } + } + 255 + }; + + let p1 = percentile(&luma_hist, 0.01); + let p50 = percentile(&luma_hist, 0.50); + let p99 = percentile(&luma_hist, 0.99); + + let black_point = p1; + let white_point = p99; + let range = (white_point as f64 - black_point as f64).max(1.0); + + let highlight_percent = + luma_hist[HIGHLIGHT_LUMA_THRESHOLD..256].iter().sum::() as f64 / total_pixels; + let clipped_percent = + luma_hist[CLIPPED_LUMA_THRESHOLD..256].iter().sum::() as f64 / total_pixels; + + let mut exposure = (EXPOSURE_MIDPOINT - p50 as f64) * EXPOSURE_SCALE; + + if white_point > WHITE_POINT_HARD_LIMIT + || highlight_percent > HIGHLIGHT_PERCENT_THRESHOLD + || clipped_percent > CLIPPED_PERCENT_THRESHOLD + { + exposure = exposure.min(0.0); + } + + if white_point as f64 + exposure > EXPOSURE_CEILING { + exposure = EXPOSURE_CEILING - white_point as f64; + } + + let mut contrast = 0.0f64; + if range < TARGET_RANGE { + contrast = ((TARGET_RANGE / range) - 1.0) * CONTRAST_SCALE; + } + if highlight_percent > HIGHLIGHT_PERCENT_THRESHOLD { + contrast *= HIGHLIGHT_CONTRAST_REDUCE; + } + + let shadow_percent = luma_hist[0..SHADOW_LUMA_MAX].iter().sum::() as f64 / total_pixels; + + let mut shadows = 0.0f64; + if shadow_percent > SHADOW_PERCENT_THRESHOLD { + shadows = (shadow_percent * SHADOW_BOOST_SCALE).min(SHADOW_MAX); + } + + let mut highlights = 0.0f64; + if highlight_percent > HIGHLIGHT_PERCENT_THRESHOLD { + highlights = -(highlight_percent * HIGHLIGHT_BOOST_SCALE).min(HIGHLIGHT_MAX); + } + + let mut vibrancy = 0.0f64; + if mean_saturation < VIBRANCY_SAT_THRESHOLD { + vibrancy = (VIBRANCY_SAT_THRESHOLD - mean_saturation) as f64 * VIBRANCY_SCALE; + } + + let mut dehaze = 0.0f64; + if range < DEHAZE_RANGE_THRESHOLD && mean_saturation < DEHAZE_SAT_THRESHOLD { + dehaze = (1.0 - range / DEHAZE_RANGE_THRESHOLD) * DEHAZE_SCALE; + } + + let mut clarity = 0.0f64; + if range < CLARITY_RANGE_THRESHOLD { + clarity = (1.0 - range / CLARITY_RANGE_THRESHOLD) * CLARITY_SCALE; + } + + let mut vignette_amount = 0.0f64; + let mut centre = 0.0f64; + + if center_n > 0 && edge_n > 0 { + let c_avg = center_sum / center_n as f32; + let e_avg = edge_sum / edge_n as f32; + + if e_avg < c_avg { + let diff = c_avg - e_avg; + vignette_amount = -(diff as f64 * VIGNETTE_SCALE); + + if diff > VIGNETTE_CENTRE_DIFF_THRESHOLD { + centre = (diff as f64 * CENTRE_SCALE).min(CENTRE_MAX); + } + } + } + + let mut adjusted_luma_hist = vec![0u32; 256]; + for pixel in rgb_image.pixels() { + let r = pixel[0] as f64; + let g = pixel[1] as f64; + let b = pixel[2] as f64; + let mut luma = LUMA_R as f64 * r + LUMA_G as f64 * g + LUMA_B as f64 * b; + luma += exposure; + luma = (luma - MID_GRAY) * (1.0 + contrast / 100.0) + MID_GRAY; + adjusted_luma_hist[luma.clamp(0.0, 255.0).round() as usize] += 1; + } + + let adj_p1 = percentile(&adjusted_luma_hist, 0.01); + let adj_p50 = percentile(&adjusted_luma_hist, 0.50); + let adj_p99 = percentile(&adjusted_luma_hist, 0.99); + let blacks: f64 = -(adj_p1 as f64 * BLACKS_SCALE); + let whites: f64 = (adj_p99 as f64 - 255.0) * WHITES_SCALE; + let brightness: f64 = (MID_GRAY - adj_p50 as f64) * BRIGHTNESS_SCALE; + + AutoAdjustmentResults { + exposure: (exposure / EXPOSURE_OUTPUT_SCALE).clamp(-5.0, 5.0), + brightness: brightness.clamp(-5.0, 5.0), + contrast: contrast.clamp(-100.0, 100.0), + highlights: highlights.clamp(-100.0, 100.0), + shadows: shadows.clamp(-100.0, 100.0), + vibrancy: vibrancy.clamp(-100.0, 100.0), + vignette_amount: vignette_amount.clamp(-100.0, 100.0), + temperature: 0.0, + tint: 0.0, + dehaze: dehaze.clamp(-100.0, 100.0), + clarity: clarity.clamp(-100.0, 100.0), + centre: centre.clamp(-100.0, 100.0), + whites: whites.clamp(-100.0, 100.0), + blacks: blacks.clamp(-100.0, 100.0), + } +} + +pub fn auto_results_to_json(results: &AutoAdjustmentResults) -> serde_json::Value { + json!({ + "exposure": results.exposure, + "brightness": results.brightness, + "contrast": results.contrast, + "highlights": results.highlights, + "shadows": results.shadows, + "vibrance": results.vibrancy, + "vignetteAmount": results.vignette_amount, + "clarity": results.clarity, + "centré": results.centre, + + "dehaze": results.dehaze, + "sectionVisibility": { + "basic": true, + "color": true, + "effects": true + }, + "whites": results.whites, + "blacks": results.blacks + }) +} + +#[tauri::command] +pub fn calculate_auto_adjustments( + state: tauri::State, +) -> Result { + let original_image = state + .original_image + .lock() + .unwrap() + .as_ref() + .ok_or("No image loaded for auto adjustments")? + .image + .clone(); + + let results = perform_auto_analysis(&original_image); + + Ok(auto_results_to_json(&results)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 597d74c1b0..cd1d031a7a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -224,6 +224,13 @@ pub fn get_or_load_lut(state: &tauri::State, path: &str) -> Result= MAX_CACHED_LUTS { + cache.clear(); + } cache.insert(path.to_string(), arc_lut.clone()); Ok(arc_lut) } @@ -2279,6 +2286,7 @@ pub fn run() { file_management::move_files, file_management::rename_folder, file_management::rename_files, + file_management::generate_export_filename, file_management::duplicate_file, file_management::show_in_finder, file_management::delete_files_from_disk, diff --git a/src/App.tsx b/src/App.tsx index 4a06b23270..96fdb2cf8d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -284,6 +284,56 @@ function App() { const sortedImageList = useSortedLibrary(); + // When the currently-focused image drops out of the filtered/sorted view (e.g. its + // rating is lowered below the active star filter), keep the cursor on the view by + // advancing to the nearest still-visible image instead of losing the selection. + // Covers both the editor (selectedImage) and the library grid (libraryActivePath). + const prevSortedListRef = useRef(sortedImageList); + useEffect(() => { + const prevList = prevSortedListRef.current; + prevSortedListRef.current = sortedImageList; + + const visiblePaths = new Set(sortedImageList.map((img) => img.path)); + + // Find the nearest path that is still visible, scanning forward from the dropped + // image's old position first, then backward. Returns null if nothing remains. + const findNearestVisible = (droppedPath: string): string | null => { + const oldIndex = prevList.findIndex((img) => img.path === droppedPath); + // Only intervene if the image was actually visible in the previous view. This + // avoids hijacking selection on unrelated changes such as switching folders/albums. + if (oldIndex === -1) return null; + for (let i = oldIndex + 1; i < prevList.length; i++) { + if (visiblePaths.has(prevList[i].path)) return prevList[i].path; + } + for (let i = oldIndex - 1; i >= 0; i--) { + if (visiblePaths.has(prevList[i].path)) return prevList[i].path; + } + return null; + }; + + if (selectedImage) { + // Editor view: the open image is the cursor. + if (visiblePaths.has(selectedImage.path)) return; + const nextPath = findNearestVisible(selectedImage.path); + if (nextPath) handleImageSelect(nextPath); + return; + } + + // Library grid view: libraryActivePath is the cursor. + const { libraryActivePath, multiSelectedPaths, setLibrary } = useLibraryStore.getState(); + if (!libraryActivePath || visiblePaths.has(libraryActivePath)) return; + const nextPath = findNearestVisible(libraryActivePath); + if (!nextPath) return; + // Preserve any still-visible members of the existing multi-selection; otherwise + // fall back to selecting just the new cursor image (mirrors a single click). + const survivingSelected = multiSelectedPaths.filter((p: string) => visiblePaths.has(p)); + setLibrary({ + multiSelectedPaths: survivingSelected.length > 0 ? survivingSelected : [nextPath], + libraryActivePath: nextPath, + selectionAnchorPath: nextPath, + }); + }, [sortedImageList, selectedImage, handleImageSelect]); + const handleLibraryRefresh = useCallback(async () => { if (currentFolderPath) { if (currentFolderPath.startsWith('Album: ')) { diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index bc643ddff1..eee2d6b9c5 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -1,10 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; import { ArrowLeft, - Cloud, Cpu, ExternalLink as ExternalLinkIcon, - Server, Info, Trash2, Wifi, @@ -14,17 +12,13 @@ import { SlidersHorizontal, Keyboard, Bookmark, - Scaling, - Image as ImageIcon, - Mouse, - Touchpad, } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { relaunch } from '@tauri-apps/plugin-process'; import { motion, AnimatePresence } from 'framer-motion'; import clsx from 'clsx'; -import { Show, SignIn, useUser, useAuth, useClerk } from '@clerk/react'; +import { Show, SignIn, useUser } from '@clerk/react'; import Button from '../ui/Button'; import ConfirmModal from '../modals/ConfirmModal'; import Dropdown, { OptionItem } from '../ui/Dropdown'; @@ -34,17 +28,20 @@ import Slider from '../ui/Slider'; import { ThemeProps, THEMES, DEFAULT_THEME_ID } from '../../utils/themes'; import { useTranslation } from 'react-i18next'; import { Invokes } from '../ui/AppProperties'; -import { - formatKeyCode, - KeybindDefinition, - KEYBIND_DEFINITIONS, - KEYBIND_SECTIONS, - normalizeCombo, -} from '../../utils/keyboardUtils'; +import { KEYBIND_DEFINITIONS, KEYBIND_SECTIONS } from '../../utils/keyboardUtils'; import Text from '../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../types/typography'; import { useOsPlatform } from '../../hooks/useOsPlatform'; import { open } from '@tauri-apps/plugin-shell'; +import { + AiProviderSwitch, + CanvasInputModeSwitch, + CloudDashboard, + DataActionItem, + KeybindRow, + PreviewModeSwitch, + SettingItem, +} from './settings/widgets'; interface ConfirmModalState { confirmText: string; @@ -55,33 +52,6 @@ interface ConfirmModalState { title: string; } -interface DataActionItemProps { - buttonAction(): void; - buttonText: string; - description: any; - disabled?: boolean; - icon: any; - isProcessing: boolean; - message: string; - title: string; -} - -interface KeybindRowProps { - def: KeybindDefinition; - currentCombo?: string[]; - osPlatform: string; - onSave: (action: string, combo: string[]) => void; - recordingAction: string | null; - onStartRecording: (action: string) => void; - isConflicting: boolean; -} - -interface SettingItemProps { - children: any; - description?: string; - label: string; -} - interface SettingsPanelProps { appSettings: any; onBack(): void; @@ -135,359 +105,6 @@ const zoomMultiplierOptions: OptionItem[] = [ { value: 0.25, label: '0.25x' }, ]; -const KeybindRow = ({ - def, - currentCombo, - osPlatform, - onSave, - recordingAction, - onStartRecording, - isConflicting, -}: KeybindRowProps) => { - const { t } = useTranslation(); - const recording = recordingAction === def.action; - - useEffect(() => { - if (!recording) return; - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onSave(def.action, []); - onStartRecording(''); - return; - } - e.preventDefault(); - const parts = normalizeCombo(e, osPlatform); - if (parts.length > 0 && !['ctrl', 'shift', 'alt'].includes(parts[parts.length - 1])) { - onSave(def.action, parts); - onStartRecording(''); - } - }; - window.addEventListener('keydown', handler, { capture: true }); - return () => window.removeEventListener('keydown', handler, { capture: true }); - }, [recording, def.action, onSave, onStartRecording]); - - const displayCombo = currentCombo !== undefined ? (currentCombo.length ? currentCombo : null) : def.defaultCombo; - - return ( -
- {t(def.description as any)} -
- {isConflicting && } - -
-
- ); -}; - -const SettingItem = ({ children, description, label }: SettingItemProps) => ( -
- - {label} - - {children} - {description && ( - - {description} - - )} -
-); - -const DataActionItem = ({ - buttonAction, - buttonText, - description, - disabled = false, - icon, - isProcessing, - message, - title, -}: DataActionItemProps) => { - const { t } = useTranslation(); - - return ( -
- - {title} - - - {description} - - - {message && ( - - {message} - - )} -
- ); -}; - -interface AiProviderSwitchProps { - selectedProvider: string; - onProviderChange: (provider: string) => void; -} - -const AiProviderSwitch = ({ selectedProvider, onProviderChange }: AiProviderSwitchProps) => { - const { t } = useTranslation(); - - const aiProviders = useMemo( - () => [ - { id: 'cpu', label: t('settings.processing.ai.providers.cpu'), icon: Cpu }, - { id: 'ai-connector', label: t('settings.processing.ai.providers.aiConnector'), icon: Server }, - //{ id: 'cloud', label: t('settings.processing.ai.providers.cloud'), icon: Cloud }, - ], - [t], - ); - - return ( -
- {aiProviders.map((provider) => ( - - ))} -
- ); -}; - -const CloudDashboard = () => { - const { user } = useUser(); - const { getToken } = useAuth(); - const { signOut } = useClerk(); - const [usage, setUsage] = useState<{ requests: number; limit: number; month: string } | null>(null); - const { t } = useTranslation(); - - useEffect(() => { - const fetchUsage = async () => { - try { - const token = await getToken(); - if (!token) return; - const res = await fetch('https://getrapidraw.com/api/usage', { - headers: { Authorization: `Bearer ${token}` }, - }); - if (res.ok) { - setUsage(await res.json()); - } - } catch (e) { - console.error('Failed to fetch cloud usage', e); - } - }; - fetchUsage(); - }, [getToken]); - - const isPro = user?.publicMetadata?.plan === 'pro'; - - return ( -
-
-
-
- {user?.fullName || user?.primaryEmailAddress?.emailAddress} - - {isPro - ? t('settings.processing.ai.cloud.signedIn.active') - : t('settings.processing.ai.cloud.signedIn.inactive')} - -
-
-
- - -
-
- - {isPro ? ( -
-
- {t('settings.processing.ai.cloud.signedIn.usage')} - - {t('settings.processing.ai.cloud.signedIn.usageStats', { - requests: usage?.requests ?? 0, - limit: usage?.limit ?? 500, - })} - -
-
-
-
-
- ) : ( -
- {t('settings.processing.ai.cloud.signedOut.upgradeDesc')} - -
- )} -
- ); -}; - -interface CanvasInputModeSwitchProps { - mode: 'mouse' | 'trackpad'; - onModeChange: (mode: 'mouse' | 'trackpad') => void; -} - -const CanvasInputModeSwitch = ({ mode, onModeChange }: CanvasInputModeSwitchProps) => { - const { t } = useTranslation(); - - const canvasInputModes = useMemo( - () => [ - { id: 'mouse', label: t('settings.controls.modes.mouse'), icon: Mouse }, - { id: 'trackpad', label: t('settings.controls.modes.trackpad'), icon: Touchpad }, - ], - [t], - ); - - return ( -
- {canvasInputModes.map((item) => ( - - ))} -
- ); -}; - -interface PreviewModeSwitchProps { - mode: 'static' | 'dynamic'; - onModeChange: (mode: 'static' | 'dynamic') => void; -} - -const PreviewModeSwitch = ({ mode, onModeChange }: PreviewModeSwitchProps) => { - const { t } = useTranslation(); - - const previewModes = useMemo( - () => [ - { id: 'static', label: t('settings.processing.modes.static'), icon: ImageIcon }, - { id: 'dynamic', label: t('settings.processing.modes.dynamic'), icon: Scaling }, - ], - [t], - ); - - return ( -
- {previewModes.map((item) => ( - - ))} -
- ); -}; export default function SettingsPanel({ appSettings, diff --git a/src/components/panel/right/ExportPanel.tsx b/src/components/panel/right/ExportPanel.tsx index aca43197ea..8f95fad3be 100644 --- a/src/components/panel/right/ExportPanel.tsx +++ b/src/components/panel/right/ExportPanel.tsx @@ -475,9 +475,19 @@ export default function ExportPanel({ let outputFolderOrFile = ''; if (numImages === 1) { - const originalFilename = pathsToExport[0].split(/[\\/]/).pop() || ''; - const stem = originalFilename.substring(0, originalFilename.lastIndexOf('.')) || originalFilename; - const suggestedName = finalFilenameTemplate.replace('{original_filename}', stem); + let suggestedName: string; + try { + // Resolve the template (dates, metadata tokens, original filename) in the + // backend so single-image export supports the same tokens as batch. + suggestedName = await invoke(Invokes.GenerateExportFilename, { + path: pathsToExport[0], + template: finalFilenameTemplate, + }); + } catch { + const originalFilename = pathsToExport[0].split(/[\\/]/).pop() || ''; + const stem = originalFilename.substring(0, originalFilename.lastIndexOf('.')) || originalFilename; + suggestedName = finalFilenameTemplate.replace('{original_filename}', stem); + } const outputFileName = `${suggestedName}.${selectedFormat.extensions[0]}`; outputFolderOrFile = isAndroid @@ -607,18 +617,18 @@ export default function ExportPanel({ )} - {numImages > 1 && ( -
- setFilenameTemplate(e.target.value)} - ref={filenameInputRef} - type="text" - value={filenameTemplate} - /> -
- {FILENAME_VARIABLES.map((variable: string) => ( +
+ setFilenameTemplate(e.target.value)} + ref={filenameInputRef} + type="text" + value={filenameTemplate} + /> +
+ {FILENAME_VARIABLES.filter((variable: string) => numImages > 1 || variable !== '{sequence}').map( + (variable: string) => ( - ))} -
-
- )} + ), + )} +
+
{fileFormat !== FileFormats.Cube && ( <> diff --git a/src/components/panel/right/MetadataPanel.tsx b/src/components/panel/right/MetadataPanel.tsx index 0932e72ec1..2168a945ab 100644 --- a/src/components/panel/right/MetadataPanel.tsx +++ b/src/components/panel/right/MetadataPanel.tsx @@ -1,7 +1,8 @@ import { useState, useMemo, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { Check, ChevronDown, ChevronRight, Plus, Star, Tag, X, User } from 'lucide-react'; +import { Check, ChevronDown, ChevronRight, Copy, Plus, Star, Tag, X, User } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; import { Invokes } from '../../ui/AppProperties'; @@ -249,6 +250,20 @@ export default function MetadataPanel() { const liveThumbnailUrl = selectedImage ? thumbnails[selectedImage.path] : undefined; const targetPaths = multiSelectedPaths?.length > 0 ? multiSelectedPaths : selectedImage ? [selectedImage.path] : []; + const isBatch = targetPaths.length > 1; + + // Copy the focused image's creator-detail fields to every selected image. + const handleSyncMetadata = () => { + if (!selectedImage || !isBatch) return; + const updates: Record = {}; + EDITABLE_FIELDS.forEach((field) => { + const raw = (selectedImage.exif?.[field.key] as string) || ''; + const clean = raw.replace(/^"|"$/g, '').trim(); + updates[field.key] = clean.toLowerCase() === 'default' ? '' : clean; + }); + handleUpdateExif(targetPaths, updates); + toast.success(t('editor.metadata.author.syncSuccess', { count: targetPaths.length })); + }; const { cameraGridSettings, lensSetting, gpsData, otherExifEntries } = useMemo(() => { const exif = selectedImage?.exif || {}; @@ -545,6 +560,20 @@ export default function MetadataPanel() { /> ); })} + {isBatch && ( +
+ + {t('editor.metadata.author.batchHint', { count: targetPaths.length })} + + +
+ )}
)} diff --git a/src/components/panel/settings/widgets.tsx b/src/components/panel/settings/widgets.tsx new file mode 100644 index 0000000000..d338453d78 --- /dev/null +++ b/src/components/panel/settings/widgets.tsx @@ -0,0 +1,400 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Cpu, + ExternalLink as ExternalLinkIcon, + Server, + Image as ImageIcon, + Scaling, + Mouse, + Touchpad, +} from 'lucide-react'; +import { motion } from 'framer-motion'; +import clsx from 'clsx'; +import { useUser, useAuth, useClerk } from '@clerk/react'; +import { open } from '@tauri-apps/plugin-shell'; +import { useTranslation } from 'react-i18next'; +import Button from '../../ui/Button'; +import Text from '../../ui/Text'; +import { TextColors, TextVariants, TextWeights } from '../../../types/typography'; +import { formatKeyCode, KeybindDefinition, normalizeCombo } from '../../../utils/keyboardUtils'; + +export interface DataActionItemProps { + buttonAction(): void; + buttonText: string; + description: any; + disabled?: boolean; + icon: any; + isProcessing: boolean; + message: string; + title: string; +} + +export interface KeybindRowProps { + def: KeybindDefinition; + currentCombo?: string[]; + osPlatform: string; + onSave: (action: string, combo: string[]) => void; + recordingAction: string | null; + onStartRecording: (action: string) => void; + isConflicting: boolean; +} + +export interface SettingItemProps { + children: any; + description?: string; + label: string; +} + +export const KeybindRow = ({ + def, + currentCombo, + osPlatform, + onSave, + recordingAction, + onStartRecording, + isConflicting, +}: KeybindRowProps) => { + const { t } = useTranslation(); + const recording = recordingAction === def.action; + + useEffect(() => { + if (!recording) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onSave(def.action, []); + onStartRecording(''); + return; + } + e.preventDefault(); + const parts = normalizeCombo(e, osPlatform); + if (parts.length > 0 && !['ctrl', 'shift', 'alt'].includes(parts[parts.length - 1])) { + onSave(def.action, parts); + onStartRecording(''); + } + }; + window.addEventListener('keydown', handler, { capture: true }); + return () => window.removeEventListener('keydown', handler, { capture: true }); + }, [recording, def.action, onSave, onStartRecording]); + + const displayCombo = currentCombo !== undefined ? (currentCombo.length ? currentCombo : null) : def.defaultCombo; + + return ( +
+ {t(def.description as any)} +
+ {isConflicting && } + +
+
+ ); +}; + +export const SettingItem = ({ children, description, label }: SettingItemProps) => ( +
+ + {label} + + {children} + {description && ( + + {description} + + )} +
+); + +export const DataActionItem = ({ + buttonAction, + buttonText, + description, + disabled = false, + icon, + isProcessing, + message, + title, +}: DataActionItemProps) => { + const { t } = useTranslation(); + + return ( +
+ + {title} + + + {description} + + + {message && ( + + {message} + + )} +
+ ); +}; + +interface AiProviderSwitchProps { + selectedProvider: string; + onProviderChange: (provider: string) => void; +} + +export const AiProviderSwitch = ({ selectedProvider, onProviderChange }: AiProviderSwitchProps) => { + const { t } = useTranslation(); + + const aiProviders = useMemo( + () => [ + { id: 'cpu', label: t('settings.processing.ai.providers.cpu'), icon: Cpu }, + { id: 'ai-connector', label: t('settings.processing.ai.providers.aiConnector'), icon: Server }, + //{ id: 'cloud', label: t('settings.processing.ai.providers.cloud'), icon: Cloud }, + ], + [t], + ); + + return ( +
+ {aiProviders.map((provider) => ( + + ))} +
+ ); +}; + +export const CloudDashboard = () => { + const { user } = useUser(); + const { getToken } = useAuth(); + const { signOut } = useClerk(); + const [usage, setUsage] = useState<{ requests: number; limit: number; month: string } | null>(null); + const { t } = useTranslation(); + + useEffect(() => { + const fetchUsage = async () => { + try { + const token = await getToken(); + if (!token) return; + const res = await fetch('https://getrapidraw.com/api/usage', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + setUsage(await res.json()); + } + } catch (e) { + console.error('Failed to fetch cloud usage', e); + } + }; + fetchUsage(); + }, [getToken]); + + const isPro = user?.publicMetadata?.plan === 'pro'; + + return ( +
+
+
+
+ {user?.fullName || user?.primaryEmailAddress?.emailAddress} + + {isPro + ? t('settings.processing.ai.cloud.signedIn.active') + : t('settings.processing.ai.cloud.signedIn.inactive')} + +
+
+
+ + +
+
+ + {isPro ? ( +
+
+ {t('settings.processing.ai.cloud.signedIn.usage')} + + {t('settings.processing.ai.cloud.signedIn.usageStats', { + requests: usage?.requests ?? 0, + limit: usage?.limit ?? 500, + })} + +
+
+
+
+
+ ) : ( +
+ {t('settings.processing.ai.cloud.signedOut.upgradeDesc')} + +
+ )} +
+ ); +}; + +interface CanvasInputModeSwitchProps { + mode: 'mouse' | 'trackpad'; + onModeChange: (mode: 'mouse' | 'trackpad') => void; +} + +export const CanvasInputModeSwitch = ({ mode, onModeChange }: CanvasInputModeSwitchProps) => { + const { t } = useTranslation(); + + const canvasInputModes = useMemo( + () => [ + { id: 'mouse', label: t('settings.controls.modes.mouse'), icon: Mouse }, + { id: 'trackpad', label: t('settings.controls.modes.trackpad'), icon: Touchpad }, + ], + [t], + ); + + return ( +
+ {canvasInputModes.map((item) => ( + + ))} +
+ ); +}; + +interface PreviewModeSwitchProps { + mode: 'static' | 'dynamic'; + onModeChange: (mode: 'static' | 'dynamic') => void; +} + +export const PreviewModeSwitch = ({ mode, onModeChange }: PreviewModeSwitchProps) => { + const { t } = useTranslation(); + + const previewModes = useMemo( + () => [ + { id: 'static', label: t('settings.processing.modes.static'), icon: ImageIcon }, + { id: 'dynamic', label: t('settings.processing.modes.dynamic'), icon: Scaling }, + ], + [t], + ); + + return ( +
+ {previewModes.map((item) => ( + + ))} +
+ ); +}; diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 0d6d78a605..9a5230e36f 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -54,6 +54,7 @@ export enum Invokes { GenerateAiForegroundMask = 'generate_ai_foreground_mask', GenerateAiSkyMask = 'generate_ai_sky_mask', GenerateAiSubjectMask = 'generate_ai_subject_mask', + GenerateExportFilename = 'generate_export_filename', GenerateFullscreenPreview = 'generate_fullscreen_preview', GeneratePreviewForPath = 'generate_preview_for_path', GenerateMaskOverlay = 'generate_mask_overlay', diff --git a/src/components/ui/ExportImportProperties.tsx b/src/components/ui/ExportImportProperties.tsx index a51dff33a5..5d39eb76c2 100644 --- a/src/components/ui/ExportImportProperties.tsx +++ b/src/components/ui/ExportImportProperties.tsx @@ -26,6 +26,10 @@ export const FILE_FORMATS: Array = [ export const FILENAME_VARIABLES: Array = [ '{original_filename}', '{sequence}', + '{title}', + '{author}', + '{copyright}', + '{comments}', '{YYYY}', '{MM}', '{DD}', @@ -33,6 +37,23 @@ export const FILENAME_VARIABLES: Array = [ '{mm}', ]; +// The original author's default export filename template. +export const DEFAULT_FILENAME_TEMPLATE = '{original_filename}_edited'; + +// Guards against a persisted/imported template that references an unknown token +// (e.g. a stray "{dcp_title}" saved into the last-used preset): an unrecognized +// {token} never gets substituted and would leak into the output filename, so we +// fall back to the default instead. +export function sanitizeFilenameTemplate(template: string | null | undefined): string { + if (!template || !template.trim()) { + return DEFAULT_FILENAME_TEMPLATE; + } + const knownTokens = new Set(FILENAME_VARIABLES); + const usedTokens = template.match(/\{[^}]+\}/g) ?? []; + const hasUnknownToken = usedTokens.some((token) => !knownTokens.has(token)); + return hasUnknownToken ? DEFAULT_FILENAME_TEMPLATE : template; +} + export interface ExportSettings { filenameTemplate: string | null; jpegQuality: number; diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/useAppNavigation.ts index 639befbd24..510d7104e9 100644 --- a/src/hooks/useAppNavigation.ts +++ b/src/hooks/useAppNavigation.ts @@ -135,7 +135,13 @@ export function useAppNavigation({ clearThumbnailQueue, refs }: AppNavigationPro } selectedImagePathRef.current = path; - setLibrary({ multiSelectedPaths: [path], libraryActivePath: null, selectionAnchorPath: path }); + // Preserve an existing multi-selection when opening an image that's part of + // it, so batch actions (e.g. metadata edits) keep targeting the whole + // selection instead of collapsing to just the opened image. + const existingSelection = useLibraryStore.getState().multiSelectedPaths; + const nextSelection = + existingSelection.length > 1 && existingSelection.includes(path) ? existingSelection : [path]; + setLibrary({ multiSelectedPaths: nextSelection, libraryActivePath: null, selectionAnchorPath: path }); setEditor({ showOriginal: false, diff --git a/src/hooks/useExportSettings.ts b/src/hooks/useExportSettings.ts index 1af496dfdd..26d196c0f7 100644 --- a/src/hooks/useExportSettings.ts +++ b/src/hooks/useExportSettings.ts @@ -1,5 +1,10 @@ import { useState, useMemo, useCallback } from 'react'; -import { ExportPreset, WatermarkAnchor } from '../components/ui/ExportImportProperties'; +import { + DEFAULT_FILENAME_TEMPLATE, + ExportPreset, + sanitizeFilenameTemplate, + WatermarkAnchor, +} from '../components/ui/ExportImportProperties'; export function useExportSettings() { const [fileFormat, setFileFormat] = useState('jpeg'); @@ -13,7 +18,7 @@ export function useExportSettings() { const [stripGps, setStripGps] = useState(true); const [exportMasks, setExportMasks] = useState(false); const [preserveFolders, setPreserveFolders] = useState(false); - const [filenameTemplate, setFilenameTemplate] = useState('{original_filename}_edited'); + const [filenameTemplate, setFilenameTemplate] = useState(DEFAULT_FILENAME_TEMPLATE); const [enableWatermark, setEnableWatermark] = useState(false); const [watermarkPath, setWatermarkPath] = useState(null); const [watermarkAnchor, setWatermarkAnchor] = useState(WatermarkAnchor.BottomRight); @@ -33,7 +38,7 @@ export function useExportSettings() { setStripGps(preset.stripGps); setExportMasks(preset.exportMasks ?? false); setPreserveFolders(preset.preserveFolders ?? false); - setFilenameTemplate(preset.filenameTemplate); + setFilenameTemplate(sanitizeFilenameTemplate(preset.filenameTemplate)); setEnableWatermark(preset.enableWatermark); setWatermarkPath(preset.watermarkPath); setWatermarkAnchor(preset.watermarkAnchor as WatermarkAnchor); diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index db61e463d0..a785cb1858 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import { ImageFile, Panel, ExifOverlay } from '../components/ui/AppProperties'; -import { KEYBIND_DEFINITIONS, normalizeCombo } from '../utils/keyboardUtils'; +import { KEYBIND_DEFINITIONS, ADJUSTMENT_NUDGES, normalizeCombo } from '../utils/keyboardUtils'; import { useEditorStore } from '../store/useEditorStore'; import { useLibraryStore } from '../store/useLibraryStore'; import { useSettingsStore } from '../store/useSettingsStore'; @@ -28,7 +28,7 @@ export const useKeyboardShortcuts = ({ handleToggleFullScreen, handleZoomChange, }: KeyboardShortcutsProps) => { - const { handleRotate, handleCopyAdjustments, handlePasteAdjustments } = useEditorActions(); + const { setAdjustments, handleRotate, handleCopyAdjustments, handlePasteAdjustments } = useEditorActions(); const { handleRate, handleSetColorLabel } = useLibraryActions(); const sortedListRef = useRef(sortedImageList); @@ -449,6 +449,25 @@ export const useKeyboardShortcuts = ({ }, }; + // Capture One–style increase/decrease shortcuts for the tonal & color + // sliders, generated from the shared ADJUSTMENT_NUDGES config so the + // keybinds and their behaviour stay in lockstep. Each nudges the global + // adjustment value and rides the same debounced-history path as the sliders. + for (const nudge of ADJUSTMENT_NUDGES) { + actions[nudge.action] = { + shouldFire: (s: any) => !!s.editor.selectedImage, + execute: (e: any) => { + e.preventDefault(); + setAdjustments((prev: any) => { + const current = typeof prev[nudge.adjustmentKey] === 'number' ? prev[nudge.adjustmentKey] : 0; + // Round to 2 decimals so repeated 0.1 EV steps don't accumulate float drift. + const next = Math.round(Math.min(nudge.max, Math.max(nudge.min, current + nudge.delta)) * 100) / 100; + return { ...prev, [nudge.adjustmentKey]: next }; + }); + }, + }; + } + const builtinShortcuts = [ { match: (e: KeyboardEvent) => e.code === 'Escape', @@ -566,6 +585,7 @@ export const useKeyboardShortcuts = ({ handlePasteFiles, handleToggleFullScreen, handleZoomChange, + setAdjustments, handleRotate, handleCopyAdjustments, handlePasteAdjustments, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bae31ddd2f..cfab1feab2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -585,7 +585,11 @@ }, "metadata": { "author": { + "batchHint": "Edits apply to all {{count}} selected images.", "creatorDetails": "Creator Details", + "syncSuccess": "Metadata synced to {{count}} images", + "syncToSelected": "Sync to {{count}} selected", + "syncTooltip": "Copy this image's creator details to all selected images", "title": "Author & Copyright" }, "camera": { @@ -1434,10 +1438,14 @@ "color_label_purple": "Color label: Purple", "color_label_red": "Color label: Red", "color_label_yellow": "Color label: Yellow", + "contrast_down": "Decrease contrast", + "contrast_up": "Increase contrast", "copy_adjustments": "Copy selected adjustments", "copy_files": "Copy selected file(s)", "cycle_zoom": "Cycle zoom (Fit, 2x Fit, 100%)", "delete_selected": "Delete selected file(s)", + "exposure_down": "Decrease exposure", + "exposure_up": "Increase exposure", "open_image": "Open selected image", "paste_adjustments": "Paste copied adjustments", "paste_files": "Paste file(s) to current folder", @@ -1452,8 +1460,14 @@ "redo": "Redo adjustment", "rotate_left": "Rotate 90° counter-clockwise", "rotate_right": "Rotate 90° clockwise", + "saturation_down": "Decrease saturation", + "saturation_up": "Increase saturation", "select_all": "Select all images", "show_original": "Show original (before/after)", + "temperature_down": "Decrease temperature (cooler)", + "temperature_up": "Increase temperature (warmer)", + "tint_down": "Decrease tint (green)", + "tint_up": "Increase tint (magenta)", "toggle_adjustments": "Toggle Adjustments panel", "toggle_ai": "Toggle AI panel", "toggle_analytics": "Toggle Analytics display", @@ -1466,6 +1480,8 @@ "toggle_metadata": "Toggle Metadata panel", "toggle_presets": "Toggle Presets panel", "undo": "Undo adjustment", + "vibrance_down": "Decrease vibrance", + "vibrance_up": "Increase vibrance", "zoom_100": "Zoom to 100%", "zoom_fit": "Zoom to fit", "zoom_in": "Zoom in", @@ -1474,6 +1490,7 @@ "zoom_out_step": "Zoom out (by step)" }, "sections": { + "adjustments": "Adjustments", "editing": "Editing", "library": "Library", "panels": "Panels", diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index 11e2a29515..c84ab64990 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -2,7 +2,7 @@ export interface KeybindDefinition { action: string; description: string; defaultCombo: string[]; - section: 'library' | 'view' | 'rating' | 'panels' | 'editing'; + section: 'library' | 'view' | 'rating' | 'panels' | 'editing' | 'adjustments'; } export interface KeybindSection { @@ -13,11 +13,50 @@ export interface KeybindSection { export const KEYBIND_SECTIONS: KeybindSection[] = [ { id: 'library', label: 'settings.keybinds.sections.library' }, { id: 'editing', label: 'settings.keybinds.sections.editing' }, + { id: 'adjustments', label: 'settings.keybinds.sections.adjustments' }, { id: 'view', label: 'settings.keybinds.sections.view' }, { id: 'rating', label: 'settings.keybinds.sections.rating' }, { id: 'panels', label: 'settings.keybinds.sections.panels' }, ]; +// Capture One–inspired increase/decrease shortcuts for the core tonal and +// color sliders. The `+` (Equal) and `-` (Minus) keys set the direction, just +// like Capture One; the modifier family selects the adjustment. Each entry also +// carries the data needed to apply it (target adjustment key, step, and clamp +// range) so the dispatcher in useKeyboardShortcuts stays config-driven and the +// keybind list cannot drift out of sync with the behaviour. +export interface AdjustmentNudge { + action: string; + description: string; + defaultCombo: string[]; + adjustmentKey: string; + delta: number; + min: number; + max: number; +} + +export const ADJUSTMENT_NUDGES: AdjustmentNudge[] = [ + // Exposure — Capture One uses Ctrl/Cmd + +/-, but that combo is bound to Zoom + // here, so exposure moves to Alt + +/-. Step matches Capture One (0.1 EV). + // Note: the UI's Exposure slider writes `brightness`; `exposure` is the EV Shift slider. + { action: 'exposure_up', description: 'settings.keybinds.actions.exposure_up', defaultCombo: ['alt', 'Equal'], adjustmentKey: 'brightness', delta: 0.1, min: -5, max: 5 }, + { action: 'exposure_down', description: 'settings.keybinds.actions.exposure_down', defaultCombo: ['alt', 'Minus'], adjustmentKey: 'brightness', delta: -0.1, min: -5, max: 5 }, + // Contrast — matches Capture One's Ctrl(+Shift+Cmd) modifier family. + { action: 'contrast_up', description: 'settings.keybinds.actions.contrast_up', defaultCombo: ['ctrl', 'shift', 'Equal'], adjustmentKey: 'contrast', delta: 5, min: -100, max: 100 }, + { action: 'contrast_down', description: 'settings.keybinds.actions.contrast_down', defaultCombo: ['ctrl', 'shift', 'Minus'], adjustmentKey: 'contrast', delta: -5, min: -100, max: 100 }, + // Saturation — matches Capture One's Ctrl(+Alt+Cmd) modifier family. + { action: 'saturation_up', description: 'settings.keybinds.actions.saturation_up', defaultCombo: ['ctrl', 'alt', 'Equal'], adjustmentKey: 'saturation', delta: 5, min: -100, max: 100 }, + { action: 'saturation_down', description: 'settings.keybinds.actions.saturation_down', defaultCombo: ['ctrl', 'alt', 'Minus'], adjustmentKey: 'saturation', delta: -5, min: -100, max: 100 }, + { action: 'vibrance_up', description: 'settings.keybinds.actions.vibrance_up', defaultCombo: ['shift', 'alt', 'Equal'], adjustmentKey: 'vibrance', delta: 5, min: -100, max: 100 }, + { action: 'vibrance_down', description: 'settings.keybinds.actions.vibrance_down', defaultCombo: ['shift', 'alt', 'Minus'], adjustmentKey: 'vibrance', delta: -5, min: -100, max: 100 }, + // Temperature / Tint together cover white balance. Temperature gets the + // simpler Shift + +/- combo; tint takes the fuller modifier family. + { action: 'temperature_up', description: 'settings.keybinds.actions.temperature_up', defaultCombo: ['shift', 'Equal'], adjustmentKey: 'temperature', delta: 5, min: -100, max: 100 }, + { action: 'temperature_down', description: 'settings.keybinds.actions.temperature_down', defaultCombo: ['shift', 'Minus'], adjustmentKey: 'temperature', delta: -5, min: -100, max: 100 }, + { action: 'tint_up', description: 'settings.keybinds.actions.tint_up', defaultCombo: ['ctrl', 'shift', 'alt', 'Equal'], adjustmentKey: 'tint', delta: 5, min: -100, max: 100 }, + { action: 'tint_down', description: 'settings.keybinds.actions.tint_down', defaultCombo: ['ctrl', 'shift', 'alt', 'Minus'], adjustmentKey: 'tint', delta: -5, min: -100, max: 100 }, +]; + export const KEYBIND_DEFINITIONS: KeybindDefinition[] = [ { action: 'open_image', @@ -255,6 +294,14 @@ export const KEYBIND_DEFINITIONS: KeybindDefinition[] = [ defaultCombo: ['ctrl', 'ArrowDown'], section: 'editing', }, + ...ADJUSTMENT_NUDGES.map( + (n): KeybindDefinition => ({ + action: n.action, + description: n.description, + defaultCombo: n.defaultCombo, + section: 'adjustments', + }), + ), ]; const symMap: Record = {