From f9758cdbfb067ceff78860e1fc19b24890a13828 Mon Sep 17 00:00:00 2001 From: Alex D Hill Date: Thu, 23 Jul 2026 20:01:29 -0700 Subject: [PATCH 1/5] Accounting for RAW colorspace in auto adjustments --- src-tauri/src/file_management.rs | 3 +- src-tauri/src/image_processing.rs | 60 ++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 56fc585659..afc815ac0d 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -2509,7 +2509,8 @@ pub async fn apply_auto_adjustments_to_paths( ) .map_err(|e| e.to_string())?; - let auto_results = perform_auto_analysis(&image); + let auto_results = + perform_auto_analysis(&image, crate::formats::is_raw_file(&source_path_str)); let auto_adjustments_json = auto_results_to_json(&auto_results); let mut existing_metadata = crate::exif_processing::load_sidecar(&sidecar_path); diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 59c9f040d3..bc109de05e 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -1,7 +1,7 @@ use crate::gpu_processing::WgpuDisplay; use bytemuck::{Pod, Zeroable}; use glam::{Mat3, Vec2, Vec3}; -use image::{DynamicImage, GenericImageView, Rgb32FImage, Rgba}; +use image::{DynamicImage, GenericImageView, ImageBuffer, Rgb, Rgb32FImage, RgbImage, Rgba}; use imageproc::geometric_transformations::{Border, Interpolation, rotate_about_center}; use nalgebra::{Matrix3 as NaMatrix3, Vector3 as NaVector3}; use rawler::decoders::Orientation; @@ -3165,7 +3165,40 @@ pub fn calculate_waveform_from_image( }) } -pub fn perform_auto_analysis(image: &DynamicImage) -> AutoAdjustmentResults { +fn linear_to_srgb(value: f32) -> f32 { + const CUTOFF: f32 = 0.0031308; + const A: f32 = 0.055; + const GAMMA: f32 = 1.0 / 2.4; + + let clamped: f32 = value.clamp(0.0, 1.0); + if clamped <= CUTOFF { + clamped * 12.92 + } else { + (1.0 + A) * clamped.powf(GAMMA) - A + } +} + +fn to_display_encoded_rgb8(image: &DynamicImage, is_raw: bool) -> RgbImage { + let linear_source: Rgb32FImage = image.to_rgb32f(); + let (width, height) = linear_source.dimensions(); + + assert!(width > 0 && height > 0, "analysis preview has zero extent"); + + ImageBuffer::from_fn(width, height, |x, y| { + let pixel: &Rgb = linear_source.get_pixel(x, y); + let encode = |channel: f32| -> u8 { + let display: f32 = if is_raw { + linear_to_srgb(channel) + } else { + channel.clamp(0.0, 1.0) + }; + (display * 255.0).round() as u8 + }; + Rgb([encode(pixel[0]), encode(pixel[1]), encode(pixel[2])]) + }) +} + +pub fn perform_auto_analysis(image: &DynamicImage, is_raw: bool) -> AutoAdjustmentResults { const ANALYSIS_MAX_DIM: u32 = 1024; const LUMA_R: f32 = 0.2126; @@ -3216,9 +3249,11 @@ pub fn perform_auto_analysis(image: &DynamicImage) -> AutoAdjustmentResults { 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 rgb_image = to_display_encoded_rgb8(&analysis_preview, is_raw); let total_pixels = (rgb_image.width() * rgb_image.height()) as f64; + assert!(total_pixels > 0.0, "auto analysis received empty image"); + 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; @@ -3415,16 +3450,15 @@ pub fn auto_results_to_json(results: &AutoAdjustmentResults) -> serde_json::Valu 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); + let (original_image, is_raw) = { + let guard = state.original_image.lock().unwrap(); + let loaded = guard + .as_ref() + .ok_or("No image loaded for auto adjustments")?; + (loaded.image.clone(), loaded.is_raw) + }; + + let results = perform_auto_analysis(&original_image, is_raw); Ok(auto_results_to_json(&results)) } From e2572518d3f3fabdb9aef32bbe331e279a360bb4 Mon Sep 17 00:00:00 2001 From: Alex D Hill Date: Thu, 23 Jul 2026 22:23:05 -0700 Subject: [PATCH 2/5] Added TaggedImage to handle raw vs non color space transformation --- src-tauri/src/color_encoding.rs | 63 +++++++++++++++++++++++++ src-tauri/src/culling.rs | 9 +++- src-tauri/src/denoising.rs | 17 ++----- src-tauri/src/file_management.rs | 4 +- src-tauri/src/hdr_deghosting.rs | 12 ++--- src-tauri/src/image_loader.rs | 40 +++++++++------- src-tauri/src/image_processing.rs | 39 +-------------- src-tauri/src/lib.rs | 8 ++-- src-tauri/src/panorama_stitching.rs | 12 ++--- src-tauri/src/raw_processing.rs | 10 +--- src-tauri/src/tagged_image.rs | 73 +++++++++++++++++++++++++++++ 11 files changed, 188 insertions(+), 99 deletions(-) create mode 100644 src-tauri/src/color_encoding.rs create mode 100644 src-tauri/src/tagged_image.rs diff --git a/src-tauri/src/color_encoding.rs b/src-tauri/src/color_encoding.rs new file mode 100644 index 0000000000..6265fa9542 --- /dev/null +++ b/src-tauri/src/color_encoding.rs @@ -0,0 +1,63 @@ +use std::sync::OnceLock; + +const SRGB_TO_LINEAR_CUTOFF: f32 = 0.04045; +const LINEAR_TO_SRGB_CUTOFF: f32 = 0.0031308; +const SRGB_ALPHA: f32 = 0.055; +const SRGB_SLOPE: f32 = 12.92; +const SRGB_GAMMA: f32 = 2.4; +const SRGB_INV_GAMMA: f32 = 1.0 / SRGB_GAMMA; + +pub fn srgb_to_linear(value: f32) -> f32 { + debug_assert!( + value.is_finite(), + "srgb_to_linear received non-finite input" + ); + + if value <= SRGB_TO_LINEAR_CUTOFF { + value / SRGB_SLOPE + } else { + ((value + SRGB_ALPHA) / (1.0 + SRGB_ALPHA)).powf(SRGB_GAMMA) + } +} + +pub fn linear_to_srgb(value: f32) -> f32 { + debug_assert!( + value.is_finite(), + "linear_to_srgb received non-finite input" + ); + + let clamped: f32 = value.clamp(0.0, 1.0); + __encode(clamped) +} + +pub fn linear_to_srgb_extended(value: f32) -> f32 { + debug_assert!( + value.is_finite(), + "linear_to_srgb_extended received non-finite input" + ); + + let floored: f32 = value.max(0.0); + __encode(floored) +} + +pub fn srgb_to_linear_lut() -> &'static [f32; 256] { + static LUT: OnceLock<[f32; 256]> = OnceLock::new(); + LUT.get_or_init(|| { + let mut lut: [f32; 256] = [0.0f32; 256]; + for (i, entry) in lut.iter_mut().enumerate() { + let code: f32 = i as f32 / 255.0; + *entry = srgb_to_linear(code); + } + lut + }) +} + +fn __encode(value: f32) -> f32 { + debug_assert!(value >= 0.0, "__encode requires a non-negative input"); + + if value <= LINEAR_TO_SRGB_CUTOFF { + value * SRGB_SLOPE + } else { + (1.0 + SRGB_ALPHA) * value.powf(SRGB_INV_GAMMA) - SRGB_ALPHA + } +} diff --git a/src-tauri/src/culling.rs b/src-tauri/src/culling.rs index 1a6d5dd90b..7bc34da36e 100644 --- a/src-tauri/src/culling.rs +++ b/src-tauri/src/culling.rs @@ -104,6 +104,9 @@ fn calculate_exposure_metric(image: &GrayImage) -> f64 { return 0.0; } + let histogram_total: u32 = histogram.channels[0].iter().sum(); + assert_eq!(histogram_total, (image.width() * image.height()) as u32, "Histogram total mismatch: expected {} pixels, found {}", (image.width() * image.height()) as u32, histogram_total); + let clip_threshold_dark = 5; let clip_threshold_bright = 250; @@ -135,8 +138,10 @@ fn analyze_image( let file_bytes = std::fs::read(path).map_err(|e| e.to_string())?; - let img = image_loader::load_base_image_from_bytes(&file_bytes, path, true, settings, None) - .map_err(|e| e.to_string())?; + let img = image_loader::load_base_image_tagged(&file_bytes, path, true, settings, None) + .map_err(|e| e.to_string())? + .into_srgb() + .into_inner(); let (width, height) = img.dimensions(); let thumbnail = img.thumbnail(ANALYSIS_DIM, ANALYSIS_DIM); diff --git a/src-tauri/src/denoising.rs b/src-tauri/src/denoising.rs index fb6bdc849e..0ced92e38a 100644 --- a/src-tauri/src/denoising.rs +++ b/src-tauri/src/denoising.rs @@ -1,9 +1,7 @@ use crate::app_settings::load_settings; use crate::app_state::AppState; use crate::file_management::parse_virtual_path; -use crate::formats::is_raw_file; -use crate::image_loader::load_base_image_from_bytes; -use crate::image_processing::apply_cpu_default_raw_processing; +use crate::image_loader::load_base_image_tagged; use base64::{Engine as _, engine::general_purpose}; use image::{DynamicImage, GenericImageView, ImageFormat, Rgb, Rgb32FImage}; use rayon::prelude::*; @@ -306,20 +304,15 @@ fn denoise_image( return Err("File not found".to_string()); } - let is_raw = is_raw_file(&path_str); let settings = load_settings(app_handle.clone()).unwrap_or_default(); let _ = app_handle.emit("denoise-progress", "Loading image..."); let file_bytes = fs::read(path).map_err(|e| e.to_string())?; - let mut dynamic_img = - load_base_image_from_bytes(&file_bytes, &path_str, false, &settings, None) - .map_err(|e| e.to_string())?; - - if is_raw { - let _ = app_handle.emit("denoise-progress", "Preparing RAW data..."); - apply_cpu_default_raw_processing(&mut dynamic_img); - } + let dynamic_img = load_base_image_tagged(&file_bytes, &path_str, false, &settings, None) + .map_err(|e| e.to_string())? + .into_srgb() + .into_inner(); let rgb_img_for_denoiser = dynamic_img.to_rgb32f(); diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index afc815ac0d..d84f4aa9a6 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -35,7 +35,7 @@ use crate::gpu_processing; use crate::image_loader; use crate::image_processing::GpuContext; use crate::image_processing::{ - Crop, ImageMetadata, apply_coarse_rotation, apply_cpu_default_raw_processing, apply_crop, + Crop, ImageMetadata, apply_coarse_rotation, apply_crop, apply_flip, apply_geometry_warp, apply_rotation, auto_results_to_json, get_all_adjustments_from_json, perform_auto_analysis, }; @@ -1502,7 +1502,7 @@ pub fn generate_thumbnail_data( } crate::image_processing::apply_cpu_agx_tonemap(&mut final_image); } else if is_raw { - apply_cpu_default_raw_processing(&mut final_image); + final_image = crate::image_processing::apply_linear_to_srgb(final_image); } } diff --git a/src-tauri/src/hdr_deghosting.rs b/src-tauri/src/hdr_deghosting.rs index a778513c92..3fc8814534 100644 --- a/src-tauri/src/hdr_deghosting.rs +++ b/src-tauri/src/hdr_deghosting.rs @@ -3,7 +3,7 @@ use crate::exif_processing::{read_exposure_time_secs, read_iso}; use crate::formats::is_raw_file; use crate::image_loader::load_base_image_from_bytes; use crate::image_processing::{ - apply_cpu_default_raw_processing, apply_linear_to_srgb, apply_srgb_to_linear, + apply_linear_to_srgb, apply_srgb_to_linear, }; use crate::panorama_stitching::{Feature, KeyPoint, Match}; use crate::panorama_utils::{processing, stitching}; @@ -110,7 +110,7 @@ pub fn align_hdr_frames(frames: &mut [HdrFrame], app_handle: &AppHandle) { let reference_index = frames.len() / 2; let detections: Vec = frames .iter() - .map(|frame| detect_frame_features(&frame.1, &brief_pairs, is_raw_file(&frame.0))) + .map(|frame| detect_frame_features(&frame.1, &brief_pairs)) .collect(); for index in 0..frames.len() { if index == reference_index { @@ -145,14 +145,8 @@ pub fn align_hdr_frames(frames: &mut [HdrFrame], app_handle: &AppHandle) { fn detect_frame_features( image: &DynamicImage, brief_pairs: &[(Point2, Point2)], - source_is_raw: bool, ) -> FrameDetection { - let mut detection_proxy = image.clone(); - if source_is_raw { - apply_cpu_default_raw_processing(&mut detection_proxy); - } else { - detection_proxy = apply_linear_to_srgb(detection_proxy); - } + let detection_proxy = apply_linear_to_srgb(image.clone()); let gray_full = image::imageops::colorops::grayscale(&detection_proxy.to_rgb8()); let (width, height) = gray_full.dimensions(); let (small_width, small_height, scale_factor) = diff --git a/src-tauri/src/image_loader.rs b/src-tauri/src/image_loader.rs index 2e638a6f06..c73f3c8e21 100644 --- a/src-tauri/src/image_loader.rs +++ b/src-tauri/src/image_loader.rs @@ -21,7 +21,6 @@ use std::collections::HashMap; use std::fs; use std::panic; use std::path::Path; -use std::sync::OnceLock; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -48,20 +47,29 @@ struct PatchMaskInfo { sub_masks: Vec, } -fn srgb_to_linear_lut() -> &'static [f32; 256] { - static LUT: OnceLock<[f32; 256]> = OnceLock::new(); - LUT.get_or_init(|| { - let mut lut = [0.0f32; 256]; - for (i, v) in lut.iter_mut().enumerate() { - let x = i as f32 / 255.0; - *v = if x <= 0.04045 { - x / 12.92 - } else { - ((x + 0.055) / 1.055).powf(2.4) - }; - } - lut - }) +pub fn load_base_image_tagged( + bytes: &[u8], + path_for_ext_check: &str, + use_fast_raw_dev: bool, + settings: &AppSettings, + cancel_token: Option<(Arc, usize)>, +) -> Result { + let is_raw: bool = is_raw_file(path_for_ext_check); + let image: DynamicImage = load_base_image_from_bytes( + bytes, + path_for_ext_check, + use_fast_raw_dev, + settings, + cancel_token, + )?; + + let encoding: crate::tagged_image::Encoding = if is_raw { + crate::tagged_image::Encoding::Linear + } else { + crate::tagged_image::Encoding::Srgb + }; + + Ok(crate::tagged_image::TaggedImage::new(image, encoding)) } pub fn load_and_composite( @@ -553,7 +561,7 @@ pub fn composite_patches_on_image( let decoded_patches = decoded_patches?; let mut composited_image = base_image.clone(); - let lut = srgb_to_linear_lut(); + let lut = crate::color_encoding::srgb_to_linear_lut(); let get_color = |patch: &DecodedPatch, r: u8, g: u8, b: u8| -> (f32, f32, f32) { if patch.is_srgb_encoded { diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index bc109de05e..9165744ead 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -1113,30 +1113,6 @@ pub fn inverse_transform_point( (x, y) } -pub fn apply_cpu_default_raw_processing(image: &mut DynamicImage) { - let mut f32_image = image.to_rgb32f(); - - const GAMMA: f32 = 2.38; - const INV_GAMMA: f32 = 1.0 / GAMMA; - const CONTRAST: f32 = 1.28; - - f32_image.par_chunks_mut(3).for_each(|pixel_chunk| { - let r_gamma = pixel_chunk[0].powf(INV_GAMMA); - let g_gamma = pixel_chunk[1].powf(INV_GAMMA); - let b_gamma = pixel_chunk[2].powf(INV_GAMMA); - - let r_contrast = (r_gamma - 0.5) * CONTRAST + 0.5; - let g_contrast = (g_gamma - 0.5) * CONTRAST + 0.5; - let b_contrast = (b_gamma - 0.5) * CONTRAST + 0.5; - - pixel_chunk[0] = r_contrast.clamp(0.0, 1.0); - pixel_chunk[1] = g_contrast.clamp(0.0, 1.0); - pixel_chunk[2] = b_contrast.clamp(0.0, 1.0); - }); - - *image = DynamicImage::ImageRgb32F(f32_image); -} - pub fn apply_srgb_to_linear(mut image: DynamicImage) -> DynamicImage { let to_linear = |x: f32| -> f32 { let x = x.max(0.0); @@ -3165,19 +3141,6 @@ pub fn calculate_waveform_from_image( }) } -fn linear_to_srgb(value: f32) -> f32 { - const CUTOFF: f32 = 0.0031308; - const A: f32 = 0.055; - const GAMMA: f32 = 1.0 / 2.4; - - let clamped: f32 = value.clamp(0.0, 1.0); - if clamped <= CUTOFF { - clamped * 12.92 - } else { - (1.0 + A) * clamped.powf(GAMMA) - A - } -} - fn to_display_encoded_rgb8(image: &DynamicImage, is_raw: bool) -> RgbImage { let linear_source: Rgb32FImage = image.to_rgb32f(); let (width, height) = linear_source.dimensions(); @@ -3188,7 +3151,7 @@ fn to_display_encoded_rgb8(image: &DynamicImage, is_raw: bool) -> RgbImage { let pixel: &Rgb = linear_source.get_pixel(x, y); let encode = |channel: f32| -> u8 { let display: f32 = if is_raw { - linear_to_srgb(channel) + crate::color_encoding::linear_to_srgb(channel) } else { channel.clamp(0.0, 1.0) }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc5d11af05..8508a7cada 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod android_integration; mod app_settings; mod app_state; mod cache_utils; +mod color_encoding; mod culling; mod denoising; mod exif_processing; @@ -32,6 +33,7 @@ mod panorama_stitching; mod panorama_utils; mod preset_converter; mod raw_processing; +mod tagged_image; mod tagging; mod tagging_utils; mod window_customizer; @@ -78,7 +80,7 @@ use crate::formats::is_raw_file; use crate::hdr_deghosting::{align_hdr_frames, assert_uniform_dimensions, load_hdr_frames}; use crate::image_loader::{composite_patches_on_image, load_and_composite}; use crate::image_processing::{ - Crop, GeometryParams, RenderRequest, apply_coarse_rotation, apply_cpu_default_raw_processing, + Crop, GeometryParams, RenderRequest, apply_coarse_rotation, apply_flip, apply_geometry_warp, apply_linear_to_srgb, downscale_f32_image, get_all_adjustments_from_json, get_or_init_gpu_context, process_and_get_dynamic_image, resolve_tonemapper_override, resolve_tonemapper_override_from_handle, warp_image_geometry, @@ -257,7 +259,7 @@ pub fn get_cached_full_warped_image( let mut cow_image = Cow::Borrowed(base_arc.as_ref()); if is_raw { - apply_cpu_default_raw_processing(cow_image.to_mut()); + cow_image = Cow::Owned(apply_linear_to_srgb(cow_image.into_owned())); } let warped_image = apply_geometry_warp(cow_image, js_adjustments).into_owned(); @@ -855,7 +857,7 @@ fn generate_original_transformed_preview( let mut image_for_preview = loaded_image.image.as_ref().clone(); if loaded_image.is_raw { - apply_cpu_default_raw_processing(&mut image_for_preview); + image_for_preview = apply_linear_to_srgb(image_for_preview); } let (transformed_full_res, _unscaled_crop_offset) = diff --git a/src-tauri/src/panorama_stitching.rs b/src-tauri/src/panorama_stitching.rs index aa8eea743b..fb7f5b7ed8 100644 --- a/src-tauri/src/panorama_stitching.rs +++ b/src-tauri/src/panorama_stitching.rs @@ -13,8 +13,6 @@ use std::path::Path; use std::time::Instant; use tauri::{AppHandle, Emitter}; -use crate::formats::is_raw_file; -use crate::image_processing::apply_cpu_default_raw_processing; use crate::panorama_utils::{processing, stitching}; pub const BRIEF_DESCRIPTOR_SIZE: usize = 256; @@ -210,18 +208,16 @@ fn stitch_images(image_paths: Vec, app_handle: AppHandle) -> Result bool { ) } -#[inline] -fn srgb_to_linear(value: f32) -> f32 { - if value <= 0.04045 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(3.0) - } -} - fn develop_internal( file_bytes: &[u8], fast_demosaic: bool, diff --git a/src-tauri/src/tagged_image.rs b/src-tauri/src/tagged_image.rs new file mode 100644 index 0000000000..e6f841281f --- /dev/null +++ b/src-tauri/src/tagged_image.rs @@ -0,0 +1,73 @@ +use crate::color_encoding::{linear_to_srgb_extended, srgb_to_linear}; +use image::DynamicImage; +use rayon::prelude::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Encoding { + Linear, + Srgb, +} + +pub struct TaggedImage { + image: DynamicImage, + encoding: Encoding, +} + +impl TaggedImage { + pub fn new(image: DynamicImage, encoding: Encoding) -> TaggedImage { + TaggedImage { image, encoding } + } + + pub fn encoding(&self) -> Encoding { + self.encoding + } + + pub fn into_srgb(mut self) -> TaggedImage { + if self.encoding == Encoding::Srgb { + return self; + } + + __convert_in_place(&mut self.image, linear_to_srgb_extended); + self.encoding = Encoding::Srgb; + debug_assert!(self.encoding == Encoding::Srgb, "into_srgb tag mismatch"); + self + } + + pub fn into_linear(mut self) -> TaggedImage { + if self.encoding == Encoding::Linear { + return self; + } + + __convert_in_place(&mut self.image, srgb_to_linear); + self.encoding = Encoding::Linear; + debug_assert!( + self.encoding == Encoding::Linear, + "into_linear tag mismatch" + ); + self + } + + pub fn as_image(&self) -> &DynamicImage { + &self.image + } + + pub fn into_inner(self) -> DynamicImage { + self.image + } +} + +fn __convert_in_place(image: &mut DynamicImage, op: fn(f32) -> f32) { + match image { + DynamicImage::ImageRgb32F(img) => { + img.as_mut().par_iter_mut().for_each(|c| *c = op(*c)); + } + DynamicImage::ImageRgba32F(img) => { + img.par_chunks_mut(4).for_each(|p| { + p[0] = op(p[0]); + p[1] = op(p[1]); + p[2] = op(p[2]); + }); + } + _ => {} + } +} From 520019f1e433dc95a579a54f99ad602c3ca0de0d Mon Sep 17 00:00:00 2001 From: Alex D Hill Date: Fri, 31 Jul 2026 13:11:56 -0700 Subject: [PATCH 3/5] Adding alternative auto modes on hold --- src-tauri/src/file_management.rs | 2 +- src-tauri/src/image_processing.rs | 402 ++++++++++++++++--- src/components/panel/right/ControlsPanel.tsx | 65 ++- src/hooks/useEditorActions.ts | 5 +- src/i18n/locales/en.json | 8 +- 5 files changed, 412 insertions(+), 70 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index d84f4aa9a6..e998a9e927 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -2510,7 +2510,7 @@ pub async fn apply_auto_adjustments_to_paths( .map_err(|e| e.to_string())?; let auto_results = - perform_auto_analysis(&image, crate::formats::is_raw_file(&source_path_str)); + perform_auto_analysis(&image, crate::image_processing::AutoMeteringMode::Percentile, crate::formats::is_raw_file(&source_path_str)); let auto_adjustments_json = auto_results_to_json(&auto_results); let mut existing_metadata = crate::exif_processing::load_sidecar(&sidecar_path); diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 9165744ead..b0484b4b2d 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -3141,6 +3141,39 @@ pub fn calculate_waveform_from_image( }) } +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutoMeteringMode { + Percentile, + GeometricMean, + Zonal, +} + +impl AutoMeteringMode { + pub fn from_opt(s: Option<&str>) -> Self { + match s { + Some("percentile") | Some("Percentile") => AutoMeteringMode::Percentile, + Some("geometricMean") | Some("GeometricMean") => AutoMeteringMode::GeometricMean, + Some("zonal") | Some("Zonal") => AutoMeteringMode::Zonal, + _ => AutoMeteringMode::Percentile, + } + } +} + +struct MeteringStats { + histogram: Vec, + total_pixels: f64, + mean_saturation: f32, + center_sum: f32, + center_n: u32, + edge_sum: f32, + edge_n: u32, + log_luma_sum: f64, + log_luma_count: u64, + zonal_cells: Vec, + grid: usize, +} + fn to_display_encoded_rgb8(image: &DynamicImage, is_raw: bool) -> RgbImage { let linear_source: Rgb32FImage = image.to_rgb32f(); let (width, height) = linear_source.dimensions(); @@ -3161,55 +3194,42 @@ fn to_display_encoded_rgb8(image: &DynamicImage, is_raw: bool) -> RgbImage { }) } -pub fn perform_auto_analysis(image: &DynamicImage, is_raw: bool) -> AutoAdjustmentResults { - const ANALYSIS_MAX_DIM: u32 = 1024; - +fn __linear_luma(r: f32, g: f32, b: f32, is_raw: bool) -> f32 { 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; + let r_lin = if is_raw { + r + } else { + crate::color_encoding::srgb_to_linear(r) + }; + let g_lin = if is_raw { + g + } else { + crate::color_encoding::srgb_to_linear(g) + }; + let b_lin = if is_raw { + b + } else { + crate::color_encoding::srgb_to_linear(b) + }; - 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; + LUMA_R * r_lin + LUMA_G * g_lin + LUMA_B * b_lin +} +fn __gather_metering_stats( + image: &DynamicImage, + is_raw: bool, + mode: AutoMeteringMode, +) -> MeteringStats { + 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 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; + const GRID: usize = 5; let analysis_preview = downscale_f32_image(image, ANALYSIS_MAX_DIM, ANALYSIS_MAX_DIM); let rgb_image = to_display_encoded_rgb8(&analysis_preview, is_raw); @@ -3260,8 +3280,66 @@ pub fn perform_auto_analysis(image: &DynamicImage, is_raw: bool) -> AutoAdjustme mean_saturation /= total_pixels as f32; + let mut log_luma_sum = 0.0f64; + let mut log_luma_count = 0u64; + let mut zonal_cells = vec![0.0f32; GRID * GRID]; + + if mode != AutoMeteringMode::Percentile { + let linear_source: Rgb32FImage = analysis_preview.to_rgb32f(); + let (src_width, src_height) = linear_source.dimensions(); + let cell_width = (src_width as f32 / GRID as f32).max(1.0); + let cell_height = (src_height as f32 / GRID as f32).max(1.0); + let mut cell_counts = vec![0u32; GRID * GRID]; + + for (x, y, pixel) in linear_source.enumerate_pixels() { + let cell_x = (x as f32 / cell_width) as usize; + let cell_y = (y as f32 / cell_height) as usize; + let cell_idx = (cell_y.min(GRID - 1)) * GRID + (cell_x.min(GRID - 1)); + + let luma = __linear_luma(pixel[0], pixel[1], pixel[2], is_raw); + if luma > 0.0 { + log_luma_sum += luma.ln() as f64; + log_luma_count += 1; + } + + zonal_cells[cell_idx] += luma; + cell_counts[cell_idx] += 1; + } + + for i in 0..zonal_cells.len() { + if cell_counts[i] > 0 { + zonal_cells[i] /= cell_counts[i] as f32; + } + } + } + + MeteringStats { + histogram: luma_hist, + total_pixels, + mean_saturation, + center_sum, + center_n, + edge_sum, + edge_n, + log_luma_sum, + log_luma_count, + zonal_cells, + grid: GRID, + } +} + +fn __exposure_from_percentile(stats: &MeteringStats) -> f64 { + 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; + let percentile = |hist: &Vec, p: f64| -> usize { - let target = (total_pixels * p) as u32; + let target = (stats.total_pixels * p) as u32; let mut cumulative = 0u32; for (i, &v) in hist.iter().enumerate() { cumulative += v; @@ -3272,18 +3350,16 @@ pub fn perform_auto_analysis(image: &DynamicImage, is_raw: bool) -> AutoAdjustme 255 }; - let p1 = percentile(&luma_hist, 0.01); - let p50 = percentile(&luma_hist, 0.50); - let p99 = percentile(&luma_hist, 0.99); + let p50 = percentile(&stats.histogram, 0.50); + let p99 = percentile(&stats.histogram, 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; + stats.histogram[HIGHLIGHT_LUMA_THRESHOLD..256].iter().sum::() as f64 + / stats.total_pixels; let clipped_percent = - luma_hist[CLIPPED_LUMA_THRESHOLD..256].iter().sum::() as f64 / total_pixels; + stats.histogram[CLIPPED_LUMA_THRESHOLD..256].iter().sum::() as f64 + / stats.total_pixels; let mut exposure = (EXPOSURE_MIDPOINT - p50 as f64) * EXPOSURE_SCALE; @@ -3298,6 +3374,150 @@ pub fn perform_auto_analysis(image: &DynamicImage, is_raw: bool) -> AutoAdjustme exposure = EXPOSURE_CEILING - white_point as f64; } + exposure +} + +// Converts EV stops to the internal display-luma-delta units the shared tail expects. +// Chain: display-luma-delta -> slider (EXPOSURE_OUTPUT_SCALE = 20) -> shader EV +// (get_val divides by SCALES.exposure = 0.8). So 1 EV = 20 * 0.8 = 16 display-luma units. +const EV_TO_DISPLAY_LUMA: f64 = 16.0; + +// Percentile-style highlight guard for the EV-based modes: never brighten a scene that +// already has blown highlights. Darkening (negative exposure) is always allowed. +fn __limit_brightening_if_clipped(exposure: f64, stats: &MeteringStats) -> f64 { + 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; + + assert!(stats.total_pixels > 0.0, "highlight guard requires pixels"); + + let highlight_percent = stats.histogram[HIGHLIGHT_LUMA_THRESHOLD..256] + .iter() + .sum::() as f64 + / stats.total_pixels; + let clipped_percent = stats.histogram[CLIPPED_LUMA_THRESHOLD..256] + .iter() + .sum::() as f64 + / stats.total_pixels; + + if highlight_percent > HIGHLIGHT_PERCENT_THRESHOLD + || clipped_percent > CLIPPED_PERCENT_THRESHOLD + { + exposure.min(0.0) + } else { + exposure + } +} + +fn __exposure_from_geometric_mean(stats: &MeteringStats) -> f64 { + assert!( + stats.log_luma_count > 0, + "geometric mean requires log luma accumulation" + ); + + let key = (stats.log_luma_sum / stats.log_luma_count as f64).exp() as f32; + assert!( + key.is_finite() && key > 0.0, + "geometric mean key must be finite and positive" + ); + + let stops = (0.18 / key).log2() as f64; + let exposure = stops * EV_TO_DISPLAY_LUMA; + + __limit_brightening_if_clipped(exposure, stats) +} + +fn __exposure_from_zonal(stats: &MeteringStats) -> f64 { + assert!( + stats.grid > 0 && !stats.zonal_cells.is_empty(), + "zonal metering requires cell grid" + ); + + let grid = stats.grid; + let mid = (grid / 2) as i32; + let mut key_sum = 0.0f32; + let mut weight_sum = 0.0f32; + + for y in 0..grid { + for x in 0..grid { + let idx = y * grid + x; + let luma = stats.zonal_cells[idx]; + + let dx = (x as i32 - mid).abs() as f32; + let dy = (y as i32 - mid).abs() as f32; + let dist = (dx * dx + dy * dy).sqrt(); + let weight = (-dist * dist / 2.0).exp(); + + key_sum += luma * weight; + weight_sum += weight; + } + } + + assert!(weight_sum > 0.0, "zonal weighting produced zero total weight"); + + let key = key_sum / weight_sum; + assert!( + key.is_finite() && key > 0.0, + "zonal key must be finite and positive" + ); + + let stops = (0.18 / key).log2() as f64; + let exposure = stops * EV_TO_DISPLAY_LUMA; + + __limit_brightening_if_clipped(exposure, stats) +} + +fn __auto_results_from_exposure( + stats: &MeteringStats, + exposure: f64, +) -> AutoAdjustmentResults { + 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_PERCENT_THRESHOLD: f64 = 0.02; + 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_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 percentile = |hist: &Vec, p: f64| -> usize { + let target = (stats.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 black_point = percentile(&stats.histogram, 0.01); + let white_point = percentile(&stats.histogram, 0.99); + let range = (white_point as f64 - black_point as f64).max(1.0); + + let highlight_percent = stats.histogram[240..256].iter().sum::() as f64 + / stats.total_pixels; + let mut contrast = 0.0f64; if range < TARGET_RANGE { contrast = ((TARGET_RANGE / range) - 1.0) * CONTRAST_SCALE; @@ -3306,7 +3526,8 @@ pub fn perform_auto_analysis(image: &DynamicImage, is_raw: bool) -> AutoAdjustme contrast *= HIGHLIGHT_CONTRAST_REDUCE; } - let shadow_percent = luma_hist[0..SHADOW_LUMA_MAX].iter().sum::() as f64 / total_pixels; + let shadow_percent = stats.histogram[0..SHADOW_LUMA_MAX].iter().sum::() as f64 + / stats.total_pixels; let mut shadows = 0.0f64; if shadow_percent > SHADOW_PERCENT_THRESHOLD { @@ -3319,12 +3540,12 @@ pub fn perform_auto_analysis(image: &DynamicImage, is_raw: bool) -> AutoAdjustme } let mut vibrancy = 0.0f64; - if mean_saturation < VIBRANCY_SAT_THRESHOLD { - vibrancy = (VIBRANCY_SAT_THRESHOLD - mean_saturation) as f64 * VIBRANCY_SCALE; + if stats.mean_saturation < VIBRANCY_SAT_THRESHOLD { + vibrancy = (VIBRANCY_SAT_THRESHOLD - stats.mean_saturation) as f64 * VIBRANCY_SCALE; } let mut dehaze = 0.0f64; - if range < DEHAZE_RANGE_THRESHOLD && mean_saturation < DEHAZE_SAT_THRESHOLD { + if range < DEHAZE_RANGE_THRESHOLD && stats.mean_saturation < DEHAZE_SAT_THRESHOLD { dehaze = (1.0 - range / DEHAZE_RANGE_THRESHOLD) * DEHAZE_SCALE; } @@ -3336,9 +3557,9 @@ pub fn perform_auto_analysis(image: &DynamicImage, is_raw: bool) -> AutoAdjustme 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 stats.center_n > 0 && stats.edge_n > 0 { + let c_avg = stats.center_sum / stats.center_n as f32; + let e_avg = stats.edge_sum / stats.edge_n as f32; if e_avg < c_avg { let diff = c_avg - e_avg; @@ -3351,14 +3572,12 @@ pub fn perform_auto_analysis(image: &DynamicImage, is_raw: bool) -> AutoAdjustme } 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; + for (i, &v) in stats.histogram.iter().enumerate() { + let i_f = i as f64; + let mut luma = i_f; 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; + adjusted_luma_hist[luma.clamp(0.0, 255.0).round() as usize] += v; } let adj_p1 = percentile(&adjusted_luma_hist, 0.01); @@ -3386,6 +3605,18 @@ pub fn perform_auto_analysis(image: &DynamicImage, is_raw: bool) -> AutoAdjustme } } +pub fn perform_auto_analysis(image: &DynamicImage, mode: AutoMeteringMode, is_raw: bool) -> AutoAdjustmentResults { + let stats = __gather_metering_stats(image, is_raw, mode); + + let exposure = match mode { + AutoMeteringMode::Percentile => __exposure_from_percentile(&stats), + AutoMeteringMode::GeometricMean => __exposure_from_geometric_mean(&stats), + AutoMeteringMode::Zonal => __exposure_from_zonal(&stats), + }; + + __auto_results_from_exposure(&stats, exposure) +} + pub fn auto_results_to_json(results: &AutoAdjustmentResults) -> serde_json::Value { json!({ "exposure": results.exposure, @@ -3412,6 +3643,7 @@ pub fn auto_results_to_json(results: &AutoAdjustmentResults) -> serde_json::Valu #[tauri::command] pub fn calculate_auto_adjustments( state: tauri::State, + mode: Option, ) -> Result { let (original_image, is_raw) = { let guard = state.original_image.lock().unwrap(); @@ -3421,7 +3653,49 @@ pub fn calculate_auto_adjustments( (loaded.image.clone(), loaded.is_raw) }; - let results = perform_auto_analysis(&original_image, is_raw); + let metering_mode = AutoMeteringMode::from_opt(mode.as_deref()); + let results = perform_auto_analysis(&original_image, metering_mode, is_raw); Ok(auto_results_to_json(&results)) } + +#[cfg(test)] +mod tests { + use super::*; + + // Inputs are LINEAR (is_raw = true) so the accumulator uses the pixel value directly, + // isolating the key/stops math. Dividing the returned display-luma exposure by + // EV_TO_DISPLAY_LUMA recovers the EV stops the mode is asking for. + #[test] + fn test_geometric_mean_mid_gray() { + let size = 64u32; + let img = Rgb32FImage::from_fn(size, size, |_, _| Rgb([0.18, 0.18, 0.18])); + let dyn_img = DynamicImage::ImageRgb32F(img); + + let stats = __gather_metering_stats(&dyn_img, true, AutoMeteringMode::GeometricMean); + let stops = __exposure_from_geometric_mean(&stats) / EV_TO_DISPLAY_LUMA; + + assert!( + stops.abs() < 0.05, + "linear 0.18 key should ask for ~0 EV, got {}", + stops + ); + } + + #[test] + fn test_geometric_mean_half_stop_dark() { + let size = 64u32; + let half_stop = 0.18f32 / 2.0f32.sqrt(); + let img = Rgb32FImage::from_fn(size, size, |_, _| Rgb([half_stop, half_stop, half_stop])); + let dyn_img = DynamicImage::ImageRgb32F(img); + + let stats = __gather_metering_stats(&dyn_img, true, AutoMeteringMode::GeometricMean); + let stops = __exposure_from_geometric_mean(&stats) / EV_TO_DISPLAY_LUMA; + + assert!( + stops > 0.45 && stops < 0.55, + "half-stop-dark linear should ask for ~+0.5 EV, got {}", + stops + ); + } +} diff --git a/src/components/panel/right/ControlsPanel.tsx b/src/components/panel/right/ControlsPanel.tsx index 5f1a013ffa..73368c2c81 100644 --- a/src/components/panel/right/ControlsPanel.tsx +++ b/src/components/panel/right/ControlsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useRef } from 'react'; import { RotateCcw, Copy, ClipboardPaste, Aperture, ChartArea } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import clsx from 'clsx'; @@ -30,6 +30,9 @@ export default function Controls() { useWaveformControls(); const { setAdjustments, handleAutoAdjustments, handleLutSelect, setLutPreviewOverride } = useEditorActions(); + const holdTimerRef = useRef | null>(null); + const heldRef = useRef(false); + const { appSettings, theme } = useSettingsStore( useShallow((state) => ({ appSettings: state.appSettings, @@ -93,6 +96,61 @@ export default function Controls() { [setUI], ); + const openAutoModeMenu = useCallback( + (anchorRect: DOMRect) => { + const options: any = [ + { + label: t('editor.adjustments.autoModes.percentile'), + onClick: () => handleAutoAdjustments('percentile'), + }, + { + label: t('editor.adjustments.autoModes.geometricMean'), + onClick: () => handleAutoAdjustments('geometricMean'), + }, + { + label: t('editor.adjustments.autoModes.zonal'), + onClick: () => handleAutoAdjustments('zonal'), + }, + ]; + showContextMenu(anchorRect.left, anchorRect.bottom, options); + }, + [handleAutoAdjustments, showContextMenu, t], + ); + + const handleAutoPointerDown = useCallback( + (e: React.PointerEvent) => { + if (!selectedImage?.isReady) { + return; + } + heldRef.current = false; + const buttonRect = e.currentTarget.getBoundingClientRect(); + holdTimerRef.current = setTimeout(() => { + heldRef.current = true; + openAutoModeMenu(buttonRect); + }, 350); + }, + [selectedImage?.isReady, openAutoModeMenu], + ); + + const handleAutoPointerUp = useCallback(() => { + if (holdTimerRef.current) { + clearTimeout(holdTimerRef.current); + holdTimerRef.current = null; + } + if (!heldRef.current) { + handleAutoAdjustments(); + } + heldRef.current = false; + }, [handleAutoAdjustments]); + + const handleAutoPointerLeave = useCallback(() => { + if (holdTimerRef.current) { + clearTimeout(holdTimerRef.current); + holdTimerRef.current = null; + } + heldRef.current = false; + }, []); + const handleToggleVisibility = (sectionName: string) => { setAdjustments((prev: Adjustments) => { const currentVisibility: SectionVisibility = prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility; @@ -215,7 +273,10 @@ export default function Controls() {