From 16f6e6ec0884f6865914bf3a2bda1b594368363c Mon Sep 17 00:00:00 2001 From: SandeepSubba Date: Fri, 26 Jun 2026 10:39:15 +0800 Subject: [PATCH 01/11] fix: backend correctness and robustness hardening - cache_utils: hash full AI-patch base64 content instead of string length in calculate_transform_hash, so two distinct patch results of identical dimensions can no longer collide and leave a stale patch composited. - ai_processing: validate AI model output tensor shapes before indexing (SAM decoder, sky-seg, U-2-Netp, Depth-Anything, LaMa). A model whose output resolution differs from the hardcoded assumption now returns a clean error instead of panicking on out-of-bounds access. - export_processing: bounds-check the frontend-supplied mask_index and guard calculate_resize_target against zero-dimension images (was Inf/NaN). - lib: cap lut_cache at 32 entries; it is keyed by path and never cleared by the session/image cache resets, so it previously grew unbounded. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/ai_processing.rs | 74 ++++++++++++++++++++++++++---- src-tauri/src/cache_utils.rs | 18 ++++---- src-tauri/src/export_processing.rs | 10 +++- src-tauri/src/lib.rs | 7 +++ 4 files changed, 89 insertions(+), 20 deletions(-) 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/lib.rs b/src-tauri/src/lib.rs index 597d74c1b0..1211c486fa 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) } From 2c9d9eed7fda7f4897eebf02cb8d34196a4cd1de Mon Sep 17 00:00:00 2001 From: SandeepSubba Date: Fri, 26 Jun 2026 10:39:28 +0800 Subject: [PATCH 02/11] refactor: extract SettingsPanel presentational widgets Move the seven self-contained widget components (KeybindRow, SettingItem, DataActionItem, AiProviderSwitch, CloudDashboard, CanvasInputModeSwitch, PreviewModeSwitch) into src/components/panel/settings/widgets.tsx and prune the now-dead imports. These widgets touch no parent state, so the cut is mechanical. SettingsPanel.tsx drops from ~2415 to ~2032 lines. Co-Authored-By: Claude Opus 4.8 --- src/components/panel/SettingsPanel.tsx | 405 +--------------------- src/components/panel/settings/widgets.tsx | 400 +++++++++++++++++++++ 2 files changed, 411 insertions(+), 394 deletions(-) create mode 100644 src/components/panel/settings/widgets.tsx 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/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) => ( + + ))} +
+ ); +}; From 75f7fefd5e6b1da4f5072f06adb3790334919ae6 Mon Sep 17 00:00:00 2001 From: SandeepSubba Date: Fri, 26 Jun 2026 10:39:28 +0800 Subject: [PATCH 03/11] refactor: split file_management and image_processing into submodules Peel cohesive clusters out of two oversized Rust files into sibling submodules, each using `use super::*` to inherit the parent's imports and re-exported via `pub use` so the existing command paths in lib.rs are unchanged: - file_management/albums.rs (AlbumItem + album commands, ~260 lines) - file_management/folder_tree.rs (FolderNode + lazy dir scanning, ~270 lines) - image_processing/analysis.rs (histogram/waveform/auto-adjust, ~710 lines) file_management.rs 3661 -> 3135, image_processing.rs 3262 -> 2555. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/file_management.rs | 536 +------------- src-tauri/src/file_management/albums.rs | 262 +++++++ src-tauri/src/file_management/folder_tree.rs | 271 +++++++ src-tauri/src/image_processing.rs | 713 +------------------ src-tauri/src/image_processing/analysis.rs | 712 ++++++++++++++++++ 5 files changed, 1253 insertions(+), 1241 deletions(-) create mode 100644 src-tauri/src/file_management/albums.rs create mode 100644 src-tauri/src/file_management/folder_tree.rs create mode 100644 src-tauri/src/image_processing/analysis.rs diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 43d8fac765..aa808ca394 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); 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)) +} From 4bf0655baa9251f2b63fea2c54f2654e6a09bbbd Mon Sep 17 00:00:00 2001 From: SandeepSubba Date: Fri, 26 Jun 2026 12:19:35 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20Capture=20One=E2=80=93style=20key?= =?UTF-8?q?board=20shortcuts=20for=20tonal=20&=20color=20adjustments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an "Adjustments" section to Settings → Controls with increase/decrease shortcuts for exposure, contrast, saturation, vibrance, temperature (white balance) and tint. Direction is the +/- keys (like Capture One); modifier families select the adjustment and match Capture One where it defines them (Contrast = Ctrl+Shift, Saturation = Ctrl+Alt). Exposure uses Alt+/- because Ctrl+/- is already bound to Zoom here. Implementation is intentionally self-contained and additive to keep it easy to rebase onto upstream releases: - keyboardUtils.ts: new `adjustments` section + a config-driven ADJUSTMENT_NUDGES table (combo, target key, step, clamp range) spread into KEYBIND_DEFINITIONS so the binds render and stay user-remappable. - useKeyboardShortcuts.ts: one generated handler per nudge, riding the same debounced-history setAdjustments path the sliders use. - en.json: section label + action labels. All defaults are remappable in Settings → Controls. Co-Authored-By: Claude Opus 4.8 --- src/hooks/useKeyboardShortcuts.ts | 24 ++++++++++++++-- src/i18n/locales/en.json | 13 +++++++++ src/utils/keyboardUtils.ts | 48 ++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 3 deletions(-) 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..3c0c1646ac 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1434,10 +1434,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 +1456,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 +1476,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 +1486,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..dae1afa275 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,49 @@ 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). + { action: 'exposure_up', description: 'settings.keybinds.actions.exposure_up', defaultCombo: ['alt', 'Equal'], adjustmentKey: 'exposure', delta: 0.1, min: -5, max: 5 }, + { action: 'exposure_down', description: 'settings.keybinds.actions.exposure_down', defaultCombo: ['alt', 'Minus'], adjustmentKey: 'exposure', 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 +293,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 = { From f58799ce5bf0a565a948e8d159ed16c910a7a426 Mon Sep 17 00:00:00 2001 From: SandeepSubba Date: Fri, 26 Jun 2026 12:20:14 +0800 Subject: [PATCH 05/11] docs: add FORK_NOTES with custom-change inventory and upstream-rebase workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the fork's custom changes (Capture One–style adjustment shortcuts + backend hardening) and a step-by-step process to rebase them onto new upstream releases, so updating stays low-effort. Co-Authored-By: Claude Opus 4.8 --- FORK_NOTES.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 FORK_NOTES.md diff --git a/FORK_NOTES.md b/FORK_NOTES.md new file mode 100644 index 0000000000..8b2b928533 --- /dev/null +++ b/FORK_NOTES.md @@ -0,0 +1,77 @@ +# 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`. + +## 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. 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 code-analysis-fixes +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 code-analysis-fixes +``` + +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 +``` From 991e2b2b41e2594cde1054dee6746f177bedbdb8 Mon Sep 17 00:00:00 2001 From: SandeepSubba Date: Fri, 26 Jun 2026 12:25:03 +0800 Subject: [PATCH 06/11] docs: document the two-branch fork structure in FORK_NOTES custom-shortcuts = full fork (fixes + refactors + shortcuts), the branch to run and rebase; code-analysis-fixes = clean fix/refactor subset for the upstream PR. Co-Authored-By: Claude Opus 4.8 --- FORK_NOTES.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/FORK_NOTES.md b/FORK_NOTES.md index 8b2b928533..4ed0548f0c 100644 --- a/FORK_NOTES.md +++ b/FORK_NOTES.md @@ -14,6 +14,16 @@ 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 @@ -45,7 +55,7 @@ candidates to be merged upstream; if they are, drop them from the fork. git fetch origin # 2. Replay our custom commits on top of the new upstream main -git checkout code-analysis-fixes +git checkout custom-shortcuts git rebase origin/main # 3. If a conflict appears (rare, since our changes are additive), fix the @@ -58,7 +68,7 @@ npm install npm start # 5. Update the fork -git push --force-with-lease fork code-analysis-fixes +git push --force-with-lease fork custom-shortcuts ``` Because the feature lives in one additive commit, the usual outcome of step 2 From dffb86acda396bda600fce5ebda5918e4c97365b Mon Sep 17 00:00:00 2001 From: SandeepSubba Date: Fri, 26 Jun 2026 15:00:15 +0800 Subject: [PATCH 07/11] feat: metadata export-naming tokens + single-image file naming - Export filename templates now support metadata tokens matching the Metadata panel's editable fields: {title} (ImageDescription), {author} (Artist), {copyright} (Copyright) and {comments} (UserComment). Values are read lazily from the image's cached EXIF and sanitized so they are safe in a filename. - The File Naming section now shows for single-image export too (previously multi-select only); {sequence} stays hidden for single images. Single-image export resolves the template via a new generate_export_filename command so it supports the same tokens (dates, metadata, original filename) as batch. - Guard against a persisted/imported template referencing an unknown token (e.g. a stray {dcp_title} saved into the last-used preset): unknown tokens now fall back to the original default {original_filename}_edited. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/file_management.rs | 65 ++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/components/panel/right/ExportPanel.tsx | 48 +++++++++------ src/components/ui/AppProperties.tsx | 1 + src/components/ui/ExportImportProperties.tsx | 21 +++++++ src/hooks/useExportSettings.ts | 11 +++- 6 files changed, 125 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index aa808ca394..e601aaa4d5 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -2735,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, @@ -2762,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/lib.rs b/src-tauri/src/lib.rs index 1211c486fa..cd1d031a7a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2286,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/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/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/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); From 8bbc8a27e461bae873c5f667018ecfa7c750d3d0 Mon Sep 17 00:00:00 2001 From: SandeepSubba Date: Fri, 26 Jun 2026 15:02:37 +0800 Subject: [PATCH 08/11] docs: add export-naming feature to FORK_NOTES custom-change inventory Co-Authored-By: Claude Opus 4.8 --- FORK_NOTES.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/FORK_NOTES.md b/FORK_NOTES.md index 4ed0548f0c..bb32e8c4c1 100644 --- a/FORK_NOTES.md +++ b/FORK_NOTES.md @@ -44,7 +44,24 @@ To change/extend: edit the single `ADJUSTMENT_NUDGES` array in dispatcher and the keybind UI are both driven from it, so nothing else needs to change. -### 2. Backend hardening + refactors (`fix:` / `refactor:` commits) +### 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. From 35dcb0bcb14d2f06510ce47ef364a04eb1c351b0 Mon Sep 17 00:00:00 2001 From: SandeepSubba Date: Fri, 26 Jun 2026 15:30:31 +0800 Subject: [PATCH 09/11] feat: batch metadata editing + sync across selected images - Opening an image that's part of a multi-selection now preserves the whole selection instead of collapsing to the opened image, so batch actions keep targeting every selected image. - Creator Details edits (Title/Author/Copyright/Comments) now apply to all selected images, with a "Edits apply to all N selected images" hint. The backend already wrote every path it received; it just never got the others. - Add a "Sync to N selected" action that copies the focused image's creator details to every selected image at once. Co-Authored-By: Claude Opus 4.8 --- src/components/panel/right/MetadataPanel.tsx | 31 +++++++++++++++++++- src/hooks/useAppNavigation.ts | 8 ++++- src/i18n/locales/en.json | 4 +++ 3 files changed, 41 insertions(+), 2 deletions(-) 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/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/i18n/locales/en.json b/src/i18n/locales/en.json index 3c0c1646ac..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": { From 137c44cac91284dacac1becd643890038ba961bb Mon Sep 17 00:00:00 2001 From: subbajeu Date: Fri, 26 Jun 2026 22:55:52 +0800 Subject: [PATCH 10/11] fix: keep selection on nearest visible image when rating filters it out When the focused image's rating drops it below the active star filter, it disappears from the filtered view but the cursor was left on the now-hidden image, breaking arrow-key navigation. Add an effect in App.tsx that, when the focused image leaves the sorted/ filtered list, advances the cursor to the nearest still-visible image (next in sort order, else previous). Covers both the editor (selectedImage) and the library grid (libraryActivePath); in the grid, still-visible members of a multi-selection are preserved. A "was it visible before" guard avoids hijacking selection on folder/album switches. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) 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: ')) { From 60ff5bc2ca726ca11046e582b67cb319c8fcd5d7 Mon Sep 17 00:00:00 2001 From: subbajeu Date: Fri, 3 Jul 2026 20:08:23 +0800 Subject: [PATCH 11/11] fix(shortcuts): exposure nudge keys targeted EV Shift, not Exposure The Alt +/- exposure shortcuts nudged the `exposure` adjustment key, which is what the EV Shift slider (inside the Tone Mapper box) writes - a linear pre-tonemap gain. The slider actually labelled "Exposure" in the Basic panel writes `brightness` (the filmic, midtone-weighted lift). Point the nudges at `brightness` so the shortcut moves the slider the user sees as Exposure. Co-Authored-By: Claude Opus 4.8 --- src/utils/keyboardUtils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index dae1afa275..c84ab64990 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -38,8 +38,9 @@ export interface AdjustmentNudge { 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). - { action: 'exposure_up', description: 'settings.keybinds.actions.exposure_up', defaultCombo: ['alt', 'Equal'], adjustmentKey: 'exposure', delta: 0.1, min: -5, max: 5 }, - { action: 'exposure_down', description: 'settings.keybinds.actions.exposure_down', defaultCombo: ['alt', 'Minus'], adjustmentKey: 'exposure', delta: -0.1, min: -5, max: 5 }, + // 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 },