diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ba759a8c2e..10fe84e021 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -34,6 +34,7 @@ dependencies = [ "libc", "little_exif", "log", + "lsdetect", "memmap2", "mimalloc", "mozjpeg-rs", @@ -3726,6 +3727,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsdetect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "042f46bce4d9622bb7243bb1d18cac317d1df7891d5f648b96590aed3d773e6f" +dependencies = [ + "rayon", +] + [[package]] name = "macro_rules_attribute" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index af61552dc4..2a5707b10e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ tokio = { version = "1", features = ["full"] } uuid = { version = "1.25", features = ["v4", "serde"] } walkdir = "2.5.0" imageproc = "0.27.0" +lsdetect = "0.3.0" ort = { version = "=2.0.0-rc.10", features = ["ndarray", "load-dynamic"] } ndarray = "0.16" tauri-plugin-process = "2.3.1" diff --git a/src-tauri/src/adjustment_utils.rs b/src-tauri/src/adjustment_utils.rs index ada9e755d1..07e007728b 100644 --- a/src-tauri/src/adjustment_utils.rs +++ b/src-tauri/src/adjustment_utils.rs @@ -90,17 +90,25 @@ pub fn hydrate_adjustments(state: &tauri::State, adjustments: &mut ser } } -pub fn apply_spatial_transformations<'a, I: IntoCowImage<'a>>( +pub fn apply_orientation_and_flip<'a, I: IntoCowImage<'a>>( image: I, adjustments: &serde_json::Value, -) -> (Cow<'a, DynamicImage>, (f32, f32)) { +) -> Cow<'a, DynamicImage> { let orientation_steps = adjustments["orientationSteps"].as_u64().unwrap_or(0) as u8; - let rotation_degrees = adjustments["rotation"].as_f64().unwrap_or(0.0) as f32; let flip_horizontal = adjustments["flipHorizontal"].as_bool().unwrap_or(false); let flip_vertical = adjustments["flipVertical"].as_bool().unwrap_or(false); let coarse_rotated_image = apply_coarse_rotation(image.into_cow(), orientation_steps); - let flipped_image = apply_flip(coarse_rotated_image, flip_horizontal, flip_vertical); + apply_flip(coarse_rotated_image, flip_horizontal, flip_vertical) +} + +pub fn apply_spatial_transformations<'a, I: IntoCowImage<'a>>( + image: I, + adjustments: &serde_json::Value, +) -> (Cow<'a, DynamicImage>, (f32, f32)) { + let rotation_degrees = adjustments["rotation"].as_f64().unwrap_or(0.0) as f32; + + let flipped_image = apply_orientation_and_flip(image, adjustments); let rotated_image = apply_rotation(flipped_image, rotation_degrees); let crop_data: Option = serde_json::from_value(adjustments["crop"].clone()).ok(); diff --git a/src-tauri/src/auto_straighten.rs b/src-tauri/src/auto_straighten.rs new file mode 100644 index 0000000000..0f34a1a728 --- /dev/null +++ b/src-tauri/src/auto_straighten.rs @@ -0,0 +1,373 @@ +use crate::AppState; +use crate::adjustment_utils::apply_orientation_and_flip; +use crate::formats::is_raw_file; +use crate::guided_perspective::{cross, unit_norm}; +use crate::image_processing::{Crop, downscale_f32_image}; +use crate::mask_generation::build_full_warped_image; +use image::{DynamicImage, GrayImage}; +use serde_json::{Value, json}; + +const ANALYSIS_MAX_DIM: u32 = 1024; +const LSD_SCALE: f64 = 0.8; +const MAX_AXIS_DEVIATION_DEGREES: f64 = 30.0; +const MIN_SEGMENT_LENGTH_FRACTION: f64 = 0.02; +const MIN_VANISHING_DISTANCE: f64 = 5.0; +const MIN_CLASS_LINES: usize = 3; +const MIN_CLASS_LENGTH_FRACTION: f64 = 0.25; +const MAX_ROTATION_DEGREES: f64 = 10.0; +const MAX_CANDIDATE_LINES: usize = 60; +const TUNING_ROUNDS: usize = 5; +const ELIMINATION_RATIO: f64 = 0.6; +const NEAR_ZERO: f64 = 1e-12; + +struct LineSegment { + line: [f64; 3], + deviation: f64, + length: f64, + vertical: bool, +} + +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn detect_segments(gray: &GrayImage) -> Vec { + let (width, height) = gray.dimensions(); + let min_length = width.max(height) as f64 * MIN_SEGMENT_LENGTH_FRACTION; + lsdetect::detect(gray.as_raw(), width as usize, height as usize, LSD_SCALE) + .unwrap_or_default() + .into_iter() + .filter_map(|segment| { + let length = segment.length(); + if length < min_length { + return None; + } + let angle = segment.angle(); + let (vertical, deviation) = if angle.abs() <= MAX_AXIS_DEVIATION_DEGREES { + (false, angle) + } else if angle.abs() >= 90.0 - MAX_AXIS_DEVIATION_DEGREES { + (true, angle - 90.0 * angle.signum()) + } else { + return None; + }; + let line = cross([segment.x1, segment.y1, 1.0], [segment.x2, segment.y2, 1.0]); + let norm = line[0].hypot(line[1]); + (norm > f64::EPSILON).then(|| LineSegment { + line: [line[0] / norm, line[1] / norm, line[2] / norm], + deviation, + length, + vertical, + }) + }) + .collect() +} + +fn vanishing_point(a: &LineSegment, b: &LineSegment, width: f64, height: f64) -> Option<[f64; 3]> { + let point = cross(a.line, b.line); + if point.iter().all(|component| component.abs() < NEAR_ZERO) { + return None; + } + if point[2].abs() > NEAR_ZERO { + let (x, y) = (point[0] / point[2], point[1] / point[2]); + if (0.0..=width).contains(&x) && (0.0..=height).contains(&y) { + return None; + } + } + Some(unit_norm(point)) +} + +fn candidate_points( + lines: &[&LineSegment], + width: f64, + height: f64, +) -> Vec<(usize, usize, [f64; 3])> { + let mut longest: Vec = (0..lines.len()).collect(); + longest.sort_by(|&a, &b| lines[b].length.total_cmp(&lines[a].length)); + longest.truncate(MAX_CANDIDATE_LINES); + longest + .iter() + .enumerate() + .flat_map(|(n, &i)| longest[n + 1..].iter().map(move |&j| (i, j))) + .filter_map(|(i, j)| { + vanishing_point(lines[i], lines[j], width, height).map(|point| (i, j, point)) + }) + .collect() +} + +fn vanishing_consensus( + lines: &[&LineSegment], + width: f64, + height: f64, +) -> (Vec, Option<[f64; 3]>) { + let count = lines.len(); + let candidates = candidate_points(lines, width, height); + if count < MIN_CLASS_LINES || candidates.is_empty() { + return (vec![false; count], None); + } + let total_length: f64 = lines.iter().map(|line| line.length).sum(); + let mut epsilon = 1e-2f64; + let mut step = 1.0f64; + + for _ in 0..TUNING_ROUNDS { + let eliminated: usize = candidates + .iter() + .map(|&(i, j, point)| { + (0..count) + .filter(|&k| k != i && k != j && dot(point, lines[k].line).abs() >= epsilon) + .count() + }) + .sum(); + let ratio = eliminated as f64 / (count * candidates.len()) as f64; + if ratio < ELIMINATION_RATIO { + epsilon = 10f64.powf(epsilon.log10() - step); + } else if ratio > ELIMINATION_RATIO { + epsilon = 10f64.powf(epsilon.log10() + step); + } + step /= 2.0; + } + + let mut best_quality = 0.0; + let mut best_inliers = vec![false; count]; + let mut best_point = None; + for &(i, j, point) in &candidates { + let mut quality = 0.0; + let inliers: Vec = (0..count) + .map(|k| { + if k == i || k == j { + return true; + } + let distance = dot(point, lines[k].line).abs(); + if distance >= epsilon { + return false; + } + let share = lines[k].length / total_length; + quality += 0.33 / count as f64 + + 0.33 * share + + 0.33 * (1.0 - distance / epsilon) * count as f64 * share; + true + }) + .collect(); + if quality > best_quality { + best_quality = quality; + best_inliers = inliers; + best_point = Some(point); + } + } + (best_inliers, best_point) +} + +fn trusted_lines( + segments: &[LineSegment], + vertical: bool, + width: f64, + height: f64, +) -> Vec<&LineSegment> { + let class: Vec<&LineSegment> = segments + .iter() + .filter(|segment| segment.vertical == vertical) + .collect(); + let (inliers, point) = vanishing_consensus(&class, width, height); + let kept: Vec<&LineSegment> = class + .into_iter() + .zip(inliers) + .filter_map(|(segment, keep)| keep.then_some(segment)) + .collect(); + let far = point.is_some_and(|p| { + p[2].abs() < NEAR_ZERO + || (p[0] / p[2] - width / 2.0).hypot(p[1] / p[2] - height / 2.0) + >= MIN_VANISHING_DISTANCE * width.hypot(height) + }); + let length: f64 = kept.iter().map(|segment| segment.length).sum(); + if far + && kept.len() >= MIN_CLASS_LINES + && length >= width.max(height) * MIN_CLASS_LENGTH_FRACTION + { + kept + } else { + Vec::new() + } +} + +fn fit_rotation(lines: &[&LineSegment]) -> f64 { + let total = lines.len() as f64; + let class_totals = |vertical: bool| -> (f64, f64) { + lines + .iter() + .filter(|line| line.vertical == vertical) + .fold((0.0, 0.0), |(length, count), line| { + (length + line.length, count + 1.0) + }) + }; + let vertical_totals = class_totals(true); + let horizontal_totals = class_totals(false); + let (sin_sum, cos_sum) = lines.iter().fold((0.0, 0.0), |(sin_sum, cos_sum), line| { + let (class_length, class_count) = if line.vertical { + vertical_totals + } else { + horizontal_totals + }; + let weight = line.length / class_length * class_count / total; + let doubled = (2.0 * line.deviation).to_radians(); + ( + sin_sum + weight * doubled.sin(), + cos_sum + weight * doubled.cos(), + ) + }); + -0.5 * sin_sum.atan2(cos_sum).to_degrees() +} + +fn detect_rotation(gray: &GrayImage) -> Option { + let (width, height) = (gray.width() as f64, gray.height() as f64); + let segments = detect_segments(gray); + let mut selected = trusted_lines(&segments, true, width, height); + selected.extend(trusted_lines(&segments, false, width, height)); + if selected.is_empty() { + return None; + } + let rotation = fit_rotation(&selected); + (rotation.abs() <= MAX_ROTATION_DEGREES).then_some(rotation) +} + +fn rotation_for_image(image: &DynamicImage, is_raw: bool, adjustments: &Value) -> Option { + let proxy = downscale_f32_image(image, ANALYSIS_MAX_DIM, ANALYSIS_MAX_DIM); + let warped = build_full_warped_image(&proxy, is_raw, adjustments); + let oriented = apply_orientation_and_flip(warped, adjustments); + detect_rotation(&oriented.to_luma8()).map(|rotation| (rotation * 10.0).round() / 10.0) +} + +fn crop_within_bounds(crop: &Crop, width: f64, height: f64, rotation: f64) -> bool { + let (sin, cos) = (-rotation).to_radians().sin_cos(); + let (cx, cy) = (width / 2.0, height / 2.0); + [ + (crop.x, crop.y), + (crop.x + crop.width, crop.y), + (crop.x, crop.y + crop.height), + (crop.x + crop.width, crop.y + crop.height), + ] + .iter() + .all(|&(x, y)| { + let rx = cos * (x - cx) - sin * (y - cy) + cx; + let ry = sin * (x - cx) + cos * (y - cy) + cy; + (-1.0..=width + 1.0).contains(&rx) && (-1.0..=height + 1.0).contains(&ry) + }) +} + +fn centered_crop(width: f64, height: f64, aspect_ratio: f64, rotation: f64) -> Crop { + let (sin, cos) = (rotation.abs() % 180.0).to_radians().sin_cos(); + let crop_height = (height / (aspect_ratio * sin + cos)).min(width / (aspect_ratio * cos + sin)); + let crop_width = aspect_ratio * crop_height; + Crop { + x: ((width - crop_width) / 2.0).round(), + y: ((height - crop_height) / 2.0).round(), + width: crop_width.round(), + height: crop_height.round(), + } +} + +fn crop_for_rotation( + width: f64, + height: f64, + aspect_ratio: Option, + rotation: f64, + current: Option, + rotation_delta: f64, +) -> Crop { + let aspect_ratio = aspect_ratio + .filter(|ratio| *ratio > 0.0) + .unwrap_or(width / height); + let Some(current) = current else { + return centered_crop(width, height, aspect_ratio, rotation); + }; + + let (sin, cos) = rotation_delta.to_radians().sin_cos(); + let (px, py) = ( + current.x + current.width / 2.0 - width / 2.0, + current.y + current.height / 2.0 - height / 2.0, + ); + let followed = Crop { + x: (width / 2.0 + px * cos - py * sin - current.width / 2.0).round(), + y: (height / 2.0 + px * sin + py * cos - current.height / 2.0).round(), + ..current + }; + if crop_within_bounds(&followed, width, height, rotation) { + return followed; + } + + let (center_x, center_y) = ( + followed.x + followed.width / 2.0, + followed.y + followed.height / 2.0, + ); + let scaled = |scale: f64| Crop { + x: center_x - followed.width * scale / 2.0, + y: center_y - followed.height * scale / 2.0, + width: followed.width * scale, + height: followed.height * scale, + }; + let (mut low, mut high) = (0.1, 1.0); + let mut best = followed; + for _ in 0..12 { + let mid = (low + high) / 2.0; + let candidate = scaled(mid); + if crop_within_bounds(&candidate, width, height, rotation) { + best = candidate; + low = mid; + } else { + high = mid; + } + } + if low < 0.15 { + return centered_crop(width, height, aspect_ratio, rotation); + } + Crop { + x: best.x.ceil(), + y: best.y.ceil(), + width: best.width.floor(), + height: best.height.floor(), + } +} + +pub fn straighten_adjustments(image: &DynamicImage, source_path: &str, adjustments: &mut Value) { + let Some(rotation) = rotation_for_image(image, is_raw_file(source_path), adjustments) else { + return; + }; + + let (image_width, image_height) = (image.width() as f64, image.height() as f64); + let (width, height) = match adjustments["orientationSteps"].as_u64().unwrap_or(0) { + 1 | 3 => (image_height, image_width), + _ => (image_width, image_height), + }; + let previous_rotation = adjustments["rotation"].as_f64().unwrap_or(0.0); + let current_crop: Option = serde_json::from_value(adjustments["crop"].clone()).ok(); + let crop = crop_for_rotation( + width, + height, + adjustments["aspectRatio"].as_f64(), + rotation, + current_crop, + rotation - previous_rotation, + ); + + adjustments["rotation"] = json!(rotation); + adjustments["crop"] = json!({ + "unit": "px", + "x": crop.x, + "y": crop.y, + "width": crop.width, + "height": crop.height, + }); +} + +#[tauri::command] +pub fn calculate_auto_straighten( + js_adjustments: Value, + state: tauri::State, +) -> Result, String> { + let (image, is_raw) = { + let guard = state.original_image.lock().unwrap(); + let loaded = guard + .as_ref() + .ok_or("No image loaded for auto straighten")?; + (loaded.image.clone(), loaded.is_raw) + }; + Ok(rotation_for_image(&image, is_raw, &js_adjustments)) +} diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 39325e564e..7cde0d2b1e 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -2905,11 +2905,15 @@ pub async fn apply_auto_lens_correction_to_paths( Ok(()) } -#[tauri::command] -pub async fn apply_auto_adjustments_to_paths( +const FULL_DECODE_WORKER_THREADS: usize = 2; + +fn update_sidecars_from_images( paths: Vec, app_handle: AppHandle, -) -> Result<(), String> { + operation: &'static str, + fast_decode: bool, + update: impl Fn(&DynamicImage, &str, &mut Value) + Send + Sync + 'static, +) { let state = app_handle.state::(); add_to_thumbnail_queue(&state, paths.len(), &app_handle); @@ -2935,7 +2939,7 @@ pub async fn apply_auto_adjustments_to_paths( let gpu_context = gpu_processing::get_or_init_gpu_context(&state, &app_handle).ok(); - paths.par_iter().for_each(|path| { + let process = |path: &String| { let loaded_image: Option = (|| -> Result { let (source_path, sidecar_path) = parse_virtual_path(path); let source_path_str = source_path.to_string_lossy().to_string(); @@ -2944,43 +2948,19 @@ pub async fn apply_auto_adjustments_to_paths( let image = image_loader::load_base_image_from_bytes( &file_bytes, &source_path_str, - true, + fast_decode, &settings, None, ) .map_err(|e| e.to_string())?; - let auto_results = perform_auto_analysis(&image); - let auto_adjustments_json = auto_results_to_json(&auto_results); - let mut existing_metadata = crate::exif_processing::load_sidecar(&sidecar_path); if existing_metadata.adjustments.is_null() { existing_metadata.adjustments = serde_json::json!({}); } - if let (Some(existing_map), Some(auto_map)) = ( - existing_metadata.adjustments.as_object_mut(), - auto_adjustments_json.as_object(), - ) { - for (k, v) in auto_map { - if k == "sectionVisibility" { - if let Some(existing_vis_val) = existing_map.get_mut(k) { - if let (Some(existing_vis), Some(auto_vis)) = - (existing_vis_val.as_object_mut(), v.as_object()) - { - for (vis_k, vis_v) in auto_vis { - existing_vis.insert(vis_k.clone(), vis_v.clone()); - } - } - } else { - existing_map.insert(k.clone(), v.clone()); - } - } else { - existing_map.insert(k.clone(), v.clone()); - } - } - } + update(&image, &source_path_str, &mut existing_metadata.adjustments); if let Ok(json_string) = serde_json::to_string_pretty(&existing_metadata) { let _ = std::fs::write(&sidecar_path, json_string); @@ -2991,7 +2971,7 @@ pub async fn apply_auto_adjustments_to_paths( } Ok(image) })() - .map_err(|e| eprintln!("Failed to apply auto adjustments to {}: {}", path, e)) + .map_err(|e| eprintln!("Failed to apply {} to {}: {}", operation, path, e)) .ok(); let result = generate_single_thumbnail_and_cache( @@ -3016,12 +2996,77 @@ pub async fn apply_auto_adjustments_to_paths( } increment_thumbnail_progress(&state, &app_handle); - }); + }; + + if fast_decode { + paths.par_iter().for_each(process); + } else if let Ok(pool) = rayon::ThreadPoolBuilder::new() + .num_threads(FULL_DECODE_WORKER_THREADS) + .build() + { + pool.install(|| paths.par_iter().for_each(process)); + } else { + paths.iter().for_each(process); + } }); +} +#[tauri::command] +pub async fn apply_auto_adjustments_to_paths( + paths: Vec, + app_handle: AppHandle, +) -> Result<(), String> { + update_sidecars_from_images( + paths, + app_handle, + "auto adjustments", + true, + |image, _, adjustments| { + let auto_results = perform_auto_analysis(image); + let auto_adjustments_json = auto_results_to_json(&auto_results); + + if let (Some(existing_map), Some(auto_map)) = ( + adjustments.as_object_mut(), + auto_adjustments_json.as_object(), + ) { + for (k, v) in auto_map { + if k == "sectionVisibility" { + if let Some(existing_vis_val) = existing_map.get_mut(k) { + if let (Some(existing_vis), Some(auto_vis)) = + (existing_vis_val.as_object_mut(), v.as_object()) + { + for (vis_k, vis_v) in auto_vis { + existing_vis.insert(vis_k.clone(), vis_v.clone()); + } + } + } else { + existing_map.insert(k.clone(), v.clone()); + } + } else { + existing_map.insert(k.clone(), v.clone()); + } + } + } + }, + ); Ok(()) } +#[tauri::command] +pub async fn apply_auto_straighten_to_paths( + paths: Vec, + app_handle: AppHandle, +) -> Result<(), String> { + update_sidecars_from_images( + paths, + app_handle, + "auto straighten", + false, + crate::auto_straighten::straighten_adjustments, + ); + + Ok(()) +} fn update_metadata_for_paths( paths: &[String], app_handle: &AppHandle, diff --git a/src-tauri/src/guided_perspective.rs b/src-tauri/src/guided_perspective.rs index aba1a260cf..4f8a2c89c6 100644 --- a/src-tauri/src/guided_perspective.rs +++ b/src-tauri/src/guided_perspective.rs @@ -40,7 +40,7 @@ pub struct GuidedResultJson { const EPS_INF: f64 = 1e-9; const EPS_DEG: f64 = 1e-9; -fn unit_norm(v: [f64; 3]) -> [f64; 3] { +pub(crate) fn unit_norm(v: [f64; 3]) -> [f64; 3] { let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); if n < EPS_INF { v @@ -49,7 +49,7 @@ fn unit_norm(v: [f64; 3]) -> [f64; 3] { } } -fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { +pub(crate) fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 80e7ef1a60..754c4e5eea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,6 +30,7 @@ mod android_integration; mod app_settings; mod app_state; mod apple_raw; +mod auto_straighten; mod cache_utils; mod camera_tethering; mod culling; @@ -2390,6 +2391,8 @@ pub fn run() { export_processing::estimate_export_sizes, image_processing::calculate_auto_adjustments, image_processing::sample_white_balance, + auto_straighten::calculate_auto_straighten, + file_management::apply_auto_straighten_to_paths, mask_generation::generate_mask_overlay, file_management::update_exif_fields, file_management::get_supported_file_types, diff --git a/src/components/panel/Editor.tsx b/src/components/panel/Editor.tsx index 440c8652d0..ca9ca0b790 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -26,7 +26,7 @@ import { useSettingsStore } from '../../store/useSettingsStore'; import { useUIStore } from '../../store/useUIStore'; import { useLibraryStore } from '../../store/useLibraryStore'; import { useAiMasking } from '../../hooks/useAiMasking'; -import { useEditorActions } from '../../hooks/useEditorActions'; +import { useEditorActions, withRotation } from '../../hooks/useEditorActions'; const parseRgb = (rgbStr: string): [number, number, number, number] => { const match = rgbStr.match(/[\d.]+/g); @@ -327,27 +327,7 @@ export default function Editor({ onBackToLibrary, onContextMenu, onImageSelect, const handleStraighten = useCallback( (angleCorrection: number) => { - setAdjustments((prev: Adjustments) => { - const newRotation = (prev.rotation || 0) + angleCorrection; - const newCrop = - selectedImage?.width && selectedImage?.height - ? calculateAutoCropForRotation( - selectedImage.width, - selectedImage.height, - prev.orientationSteps || 0, - prev.aspectRatio, - newRotation, - prev.crop, - angleCorrection, - ) - : prev.crop; - - return { - ...prev, - rotation: newRotation, - crop: newCrop, - }; - }); + setAdjustments((prev: Adjustments) => withRotation(prev, selectedImage, (prev.rotation || 0) + angleCorrection)); setEditor({ isStraightenActive: false }); }, [selectedImage, setAdjustments, setEditor], diff --git a/src/components/panel/right/CropPanel.tsx b/src/components/panel/right/CropPanel.tsx index 4d99aeaa04..a4d28a9669 100644 --- a/src/components/panel/right/CropPanel.tsx +++ b/src/components/panel/right/CropPanel.tsx @@ -25,6 +25,7 @@ import { Info, Ban, Save, + Wand2, } from 'lucide-react'; import { useTranslation, Trans } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; @@ -98,10 +99,11 @@ export default function CropPanel() { const selectedImage = useEditorStore((s) => s.selectedImage); const adjustments = useEditorStore((s) => s.adjustments); const isStraightenActive = useEditorStore((s) => s.isStraightenActive); + const isAutoStraightening = useEditorStore((s) => s.isAutoStraightening); const isGuidedPerspectiveActive = useEditorStore((s) => s.isGuidedPerspectiveActive); const activeOverlay = useEditorStore((s) => s.overlayMode); const setEditor = useEditorStore((s) => s.setEditor); - const { setAdjustments, handleRotate } = useEditorActions(); + const { setAdjustments, handleRotate, handleAutoStraighten } = useEditorActions(); const [customW, setCustomW] = useState(''); const [customH, setCustomH] = useState(''); @@ -1001,6 +1003,14 @@ export default function CropPanel() { > +