From 8830dc0f711d2953b94f3c49b4e97fe04e0998d1 Mon Sep 17 00:00:00 2001 From: marclaliberte Date: Wed, 30 Sep 2026 21:31:50 -0400 Subject: [PATCH 1/7] feat(adjustments): add visibility toggles for color grading and mixer The Color panel eye bypasses every color tool at once. Color Grading and Color Mixer now get their own eye toggle, stored per image and per mask as sectionVisibility.colorGrading / colorMixer (default on, so existing sidecars render unchanged). The renderer applies each only when both the Color panel and the tool are visible. Resetting or pasting a section re-enables its tool toggles. The eye button is extracted into a shared VisibilityToggle used by both CollapsibleSection and AdjustmentSubSection, and the Rust section visibility lookup is shared between the global and mask builders. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 879b4146fa4d87ecfa3580261cfc0554ffb1555f) --- src-tauri/src/image_processing.rs | 62 ++++++++++--------- .../adjustments/AdjustmentSubSection.tsx | 37 +++++++++-- src/components/adjustments/Color.tsx | 16 ++++- src/components/panel/right/ControlsPanel.tsx | 19 +++--- src/components/panel/right/MasksPanel.tsx | 19 +++--- src/components/ui/CollapsibleSection.tsx | 26 +++----- src/components/ui/VisibilityToggle.tsx | 29 +++++++++ src/utils/adjustments.ts | 16 +++++ 8 files changed, 156 insertions(+), 68 deletions(-) create mode 100644 src/components/ui/VisibilityToggle.tsx diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 71d37986b..5bfc18100 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -2086,18 +2086,24 @@ pub fn is_image_edited( bytemuck::bytes_of(¤t_adj) != bytemuck::bytes_of(&default_adj) } +fn is_section_visible(adjustments: &serde_json::Value, section: &str) -> bool { + adjustments + .get("sectionVisibility") + .and_then(|v| v.get(section)) + .and_then(|s| s.as_bool()) + .unwrap_or(true) +} + +fn is_color_tool_visible(adjustments: &serde_json::Value, tool: &str) -> bool { + is_section_visible(adjustments, "color") && is_section_visible(adjustments, tool) +} + fn get_global_adjustments_from_json( js_adjustments: &serde_json::Value, is_raw: bool, tonemapper_override: Option, ) -> GlobalAdjustments { - let visibility = js_adjustments.get("sectionVisibility"); - let is_visible = |section: &str| -> bool { - visibility - .and_then(|v| v.get(section)) - .and_then(|s| s.as_bool()) - .unwrap_or(true) - }; + let is_visible = |section: &str| is_section_visible(js_adjustments, section); let get_val = |section: &str, key: &str, scale: f32, default: Option| -> f32 { if is_visible(section) { @@ -2158,6 +2164,9 @@ fn get_global_adjustments_from_json( Vec::new() }; + let color_grading_visible = is_color_tool_visible(js_adjustments, "colorGrading"); + let color_mixer_visible = is_color_tool_visible(js_adjustments, "colorMixer"); + let cg_obj = js_adjustments .get("colorGrading") .cloned() @@ -2318,32 +2327,32 @@ fn get_global_adjustments_from_json( _pad_cg2: 0.0, _pad_cg3: 0.0, _pad_cg4: 0.0, - color_grading_shadows: if is_visible("color") { + color_grading_shadows: if color_grading_visible { parse_color_grade_settings(&cg_obj["shadows"]) } else { ColorGradeSettings::default() }, - color_grading_midtones: if is_visible("color") { + color_grading_midtones: if color_grading_visible { parse_color_grade_settings(&cg_obj["midtones"]) } else { ColorGradeSettings::default() }, - color_grading_highlights: if is_visible("color") { + color_grading_highlights: if color_grading_visible { parse_color_grade_settings(&cg_obj["highlights"]) } else { ColorGradeSettings::default() }, - color_grading_global: if is_visible("color") { + color_grading_global: if color_grading_visible { parse_color_grade_settings(&cg_obj["global"]) } else { ColorGradeSettings::default() }, - color_grading_blending: if is_visible("color") { + color_grading_blending: if color_grading_visible { cg_obj["blending"].as_f64().unwrap_or(50.0) as f32 / SCALES.color_grading_blending } else { 0.5 }, - color_grading_balance: if is_visible("color") { + color_grading_balance: if color_grading_visible { cg_obj["balance"].as_f64().unwrap_or(0.0) as f32 / SCALES.color_grading_balance } else { 0.0 @@ -2353,7 +2362,7 @@ fn get_global_adjustments_from_json( color_calibration: color_cal_settings, - hsl: if is_visible("color") { + hsl: if color_mixer_visible { parse_hsl_adjustments(&js_adjustments.get("hsl").cloned().unwrap_or_default()) } else { [HslColor::default(); 8] @@ -2388,13 +2397,7 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { return MaskAdjustments::default(); } - let visibility = adj.get("sectionVisibility"); - let is_visible = |section: &str| -> bool { - visibility - .and_then(|v| v.get(section)) - .and_then(|s| s.as_bool()) - .unwrap_or(true) - }; + let is_visible = |section: &str| is_section_visible(adj, section); let get_val = |section: &str, key: &str, scale: f32| -> f32 { if is_visible(section) { @@ -2425,6 +2428,9 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { } else { Vec::new() }; + let color_grading_visible = is_color_tool_visible(adj, "colorGrading"); + let color_mixer_visible = is_color_tool_visible(adj, "colorMixer"); + let cg_obj = adj.get("colorGrading").cloned().unwrap_or_default(); MaskAdjustments { @@ -2461,32 +2467,32 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { hue: get_val("color", "hue", 1.0), _pad_cg1: 0.0, _pad_cg2: 0.0, - color_grading_shadows: if is_visible("color") { + color_grading_shadows: if color_grading_visible { parse_color_grade_settings(&cg_obj["shadows"]) } else { ColorGradeSettings::default() }, - color_grading_midtones: if is_visible("color") { + color_grading_midtones: if color_grading_visible { parse_color_grade_settings(&cg_obj["midtones"]) } else { ColorGradeSettings::default() }, - color_grading_highlights: if is_visible("color") { + color_grading_highlights: if color_grading_visible { parse_color_grade_settings(&cg_obj["highlights"]) } else { ColorGradeSettings::default() }, - color_grading_global: if is_visible("color") { + color_grading_global: if color_grading_visible { parse_color_grade_settings(&cg_obj["global"]) } else { ColorGradeSettings::default() }, - color_grading_blending: if is_visible("color") { + color_grading_blending: if color_grading_visible { cg_obj["blending"].as_f64().unwrap_or(50.0) as f32 / SCALES.color_grading_blending } else { 0.5 }, - color_grading_balance: if is_visible("color") { + color_grading_balance: if color_grading_visible { cg_obj["balance"].as_f64().unwrap_or(0.0) as f32 / SCALES.color_grading_balance } else { 0.0 @@ -2494,7 +2500,7 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { _pad5: 0.0, _pad6: 0.0, - hsl: if is_visible("color") { + hsl: if color_mixer_visible { parse_hsl_adjustments(&adj.get("hsl").cloned().unwrap_or_default()) } else { [HslColor::default(); 8] diff --git a/src/components/adjustments/AdjustmentSubSection.tsx b/src/components/adjustments/AdjustmentSubSection.tsx index 5f2cf80bd..4ebd2f05a 100644 --- a/src/components/adjustments/AdjustmentSubSection.tsx +++ b/src/components/adjustments/AdjustmentSubSection.tsx @@ -3,6 +3,7 @@ import { ChevronDown } from 'lucide-react'; import clsx from 'clsx'; import { useShallow } from 'zustand/react/shallow'; import Text from '../ui/Text'; +import VisibilityToggle from '../ui/VisibilityToggle'; import { TextVariants } from '../../types/typography'; import { useSettingsStore } from '../../store/useSettingsStore'; import { useCollapsibleHeight } from '../../hooks/useCollapsibleHeight'; @@ -12,11 +13,21 @@ interface AdjustmentSubSectionProps { actions?: ReactNode; children: ReactNode; id: string; + isContentVisible?: boolean; + onToggleVisibility?: () => void; order: number; title: string; } -export default function AdjustmentSubSection({ actions, children, id, order, title }: AdjustmentSubSectionProps) { +export default function AdjustmentSubSection({ + actions, + children, + id, + isContentVisible = true, + onToggleVisibility, + order, + title, +}: AdjustmentSubSectionProps) { const { appSettings, handleSettingsChange } = useSettingsStore( useShallow((state) => ({ appSettings: state.appSettings, @@ -48,10 +59,22 @@ export default function AdjustmentSubSection({ actions, children, id, order, tit return (
-
- +
+ {title} + {onToggleVisibility && ( +
+ +
+ )} {actions &&
e.stopPropagation()}>{actions}
}
-
+
{children}
diff --git a/src/components/adjustments/Color.tsx b/src/components/adjustments/Color.tsx index 8a604073a..b0caf055f 100644 --- a/src/components/adjustments/Color.tsx +++ b/src/components/adjustments/Color.tsx @@ -4,7 +4,13 @@ import { motion, AnimatePresence } from 'framer-motion'; import { useTranslation } from 'react-i18next'; import Slider from '../ui/Slider'; import ColorWheel from '../ui/ColorWheel'; -import { ColorAdjustment, ColorCalibration, HueSatLum, INITIAL_ADJUSTMENTS } from '../../utils/adjustments'; +import { + ColorAdjustment, + ColorCalibration, + HueSatLum, + INITIAL_ADJUSTMENTS, + SectionVisibility, +} from '../../utils/adjustments'; import { Adjustments, ColorGrading, getAdjustmentToolOrder, getHiddenAdjustmentTools } from '../../utils/adjustments'; import { AppSettings } from '../ui/AppProperties'; import Text from '../ui/Text'; @@ -23,6 +29,8 @@ interface ColorPanelProps { appSettings: AppSettings | null; isForMask?: boolean; isWbPickerActive?: boolean; + onToggleVisibility?: (key: string) => void; + sectionVisibility?: SectionVisibility; toggleWbPicker?: () => void; onDragStateChange?: (isDragging: boolean) => void; } @@ -399,6 +407,8 @@ export default function ColorPanel({ isWbPickerActive = false, toggleWbPicker, onDragStateChange, + onToggleVisibility, + sectionVisibility, }: ColorPanelProps) { const { t } = useTranslation(); const [activeColor, setActiveColor] = useState('reds'); @@ -560,6 +570,8 @@ export default function ColorPanel({ {!hiddenTools.includes('colorGrading') && ( onToggleVisibility('colorGrading'))} order={toolOrder.indexOf('colorGrading')} title={t('adjustments.color.colorGrading')} > @@ -575,6 +587,8 @@ export default function ColorPanel({ {!hiddenTools.includes('colorMixer') && ( onToggleVisibility('colorMixer'))} order={toolOrder.indexOf('colorMixer')} title={t('adjustments.color.colorMixer')} > diff --git a/src/components/panel/right/ControlsPanel.tsx b/src/components/panel/right/ControlsPanel.tsx index c065eea07..713b777f3 100644 --- a/src/components/panel/right/ControlsPanel.tsx +++ b/src/components/panel/right/ControlsPanel.tsx @@ -18,6 +18,7 @@ import { INITIAL_ADJUSTMENTS, ADJUSTMENT_SECTIONS, getVisibleAdjustmentSections, + showSectionAndTools, } from '../../../utils/adjustments'; import { useContextMenu } from '../../../context/ContextMenuContext'; import { OPTION_SEPARATOR, Orientation } from '../../ui/AppProperties'; @@ -169,10 +170,10 @@ export default function Controls() { setAdjustments((prev: Adjustments) => ({ ...prev, ...copiedSectionAdjustments.values, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -184,10 +185,10 @@ export default function Controls() { setAdjustments((prev: Adjustments) => ({ ...prev, ...resetValues, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -321,6 +322,8 @@ export default function Controls() { isWbPickerActive={isWbPickerActive} toggleWbPicker={toggleWbPicker} onDragStateChange={onDragStateChange} + onToggleVisibility={handleToggleVisibility} + sectionVisibility={sectionVisibility} />
diff --git a/src/components/panel/right/MasksPanel.tsx b/src/components/panel/right/MasksPanel.tsx index c28645bf9..ab48b882e 100644 --- a/src/components/panel/right/MasksPanel.tsx +++ b/src/components/panel/right/MasksPanel.tsx @@ -80,6 +80,7 @@ import { MaskContainer, ADJUSTMENT_SECTIONS, getVisibleAdjustmentSections, + showSectionAndTools, } from '../../../utils/adjustments'; import { useContextMenu } from '../../../context/ContextMenuContext'; import { OPTION_SEPARATOR, Orientation, Panel } from '../../ui/AppProperties'; @@ -2059,10 +2060,10 @@ function SettingsPanel({ setMaskContainerAdjustments((prev: any) => ({ ...prev, ...copiedSectionAdjustments.values, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -2076,10 +2077,10 @@ function SettingsPanel({ setMaskContainerAdjustments((prev: any) => ({ ...prev, ...resetValues, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -2292,6 +2293,8 @@ function SettingsPanel({ isForMask={true} appSettings={appSettings} onDragStateChange={onDragStateChange} + onToggleVisibility={handleToggleVisibility} + sectionVisibility={sectionVisibility} /> ); diff --git a/src/components/ui/CollapsibleSection.tsx b/src/components/ui/CollapsibleSection.tsx index 3296621e3..d35f77972 100644 --- a/src/components/ui/CollapsibleSection.tsx +++ b/src/components/ui/CollapsibleSection.tsx @@ -1,8 +1,8 @@ import { useRef, useState } from 'react'; -import { ChevronDown, Eye, EyeOff } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; import clsx from 'clsx'; -import { useTranslation } from 'react-i18next'; import Text from './Text'; +import VisibilityToggle from './VisibilityToggle'; import { TextVariants, TextWeights } from '../../types/typography'; import { useCollapsibleHeight } from '../../hooks/useCollapsibleHeight'; @@ -27,7 +27,6 @@ export default function CollapsibleSection({ onToggleVisibility = () => {}, title, }: CollapsibleSectionProps) { - const { t } = useTranslation(); const { contentRef, wrapperRef } = useCollapsibleHeight(isOpen); const [isHovering, setIsHovering] = useState(false); const hoverTimeoutRef = useRef(null); @@ -49,11 +48,6 @@ export default function CollapsibleSection({ setIsHovering(false); }; - const handleVisibilityClick = (e: any) => { - e.stopPropagation(); - onToggleVisibility(); - }; - return (
{canToggleVisibility && (
- + isVisible={isContentVisible} + onToggle={onToggleVisibility} + />
)}
diff --git a/src/components/ui/VisibilityToggle.tsx b/src/components/ui/VisibilityToggle.tsx new file mode 100644 index 000000000..85a87c2e1 --- /dev/null +++ b/src/components/ui/VisibilityToggle.tsx @@ -0,0 +1,29 @@ +import { Eye, EyeOff } from 'lucide-react'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; + +interface VisibilityToggleProps { + className?: string; + isVisible: boolean; + onToggle(): void; +} + +export default function VisibilityToggle({ className, isVisible, onToggle }: VisibilityToggleProps) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/src/utils/adjustments.ts b/src/utils/adjustments.ts index e02aa5b75..15c737a85 100644 --- a/src/utils/adjustments.ts +++ b/src/utils/adjustments.ts @@ -385,6 +385,8 @@ export interface SectionVisibility { basic: boolean; curves: boolean; color: boolean; + colorGrading: boolean; + colorMixer: boolean; details: boolean; effects: boolean; } @@ -490,6 +492,8 @@ export const INITIAL_MASK_ADJUSTMENTS: MaskAdjustments = { basic: true, curves: true, color: true, + colorGrading: true, + colorMixer: true, details: true, effects: true, }, @@ -587,6 +591,8 @@ export const INITIAL_ADJUSTMENTS: Adjustments = { basic: true, curves: true, color: true, + colorGrading: true, + colorMixer: true, details: true, effects: true, }, @@ -1020,5 +1026,15 @@ export const ADJUSTMENT_SECTION_TOOLS: Record (ADJUSTMENT_SECTION_TOOLS[section] ?? []).map((tool) => tool.id); +export const showSectionAndTools = (visibility: SectionVisibility, section: string): SectionVisibility => ({ + ...visibility, + ...Object.fromEntries( + getAdjustmentSectionToolIds(section) + .filter((id) => id in visibility) + .map((id) => [id, true]), + ), + [section]: true, +}); + export const getAdjustmentToolOrder = (section: string, toolOrder?: Record): string[] => reconcileOrder(getAdjustmentSectionToolIds(section), toolOrder?.[section]); From 2c46f15eb3d0647f1125c71eb824adcb310a3c0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:26:48 +0000 Subject: [PATCH 2/7] Treat a missing section visibility key as visible when toggling A sectionVisibility object without the new colorGrading/colorMixer keys (an older or partial one) made the first eye click a no-op, because !undefined is true. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SHAmUdMPzKcgwDMEftFCrN --- src/components/panel/right/ControlsPanel.tsx | 2 +- src/components/panel/right/MasksPanel.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/panel/right/ControlsPanel.tsx b/src/components/panel/right/ControlsPanel.tsx index 713b777f3..ad5fd92a2 100644 --- a/src/components/panel/right/ControlsPanel.tsx +++ b/src/components/panel/right/ControlsPanel.tsx @@ -110,7 +110,7 @@ export default function Controls() { ...prev, sectionVisibility: { ...currentVisibility, - [sectionName]: !currentVisibility[sectionName], + [sectionName]: !(currentVisibility[sectionName] ?? true), }, }; }); diff --git a/src/components/panel/right/MasksPanel.tsx b/src/components/panel/right/MasksPanel.tsx index ab48b882e..e8f1bd87d 100644 --- a/src/components/panel/right/MasksPanel.tsx +++ b/src/components/panel/right/MasksPanel.tsx @@ -2032,7 +2032,7 @@ function SettingsPanel({ const cur = container.adjustments; const vis = cur.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility; updateContainer(container.id, { - adjustments: { ...cur, sectionVisibility: { ...vis, [sectionName]: !vis[sectionName] } }, + adjustments: { ...cur, sectionVisibility: { ...vis, [sectionName]: !(vis[sectionName] ?? true) } }, }); }; From ba8c6f276dce07764985466e3d06dec98cc23308 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:27:08 +0000 Subject: [PATCH 3/7] Change list and credits: Color Grading / Color Mixer visibility toggles Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SHAmUdMPzKcgwDMEftFCrN --- .github/README.md | 2 +- CHANGES.md | 12 +++++++++++- CREDITS.md | 1 + rapidroom/changes.json | 13 +++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/README.md b/.github/README.md index d40fceb12..5399bad2f 100644 --- a/.github/README.md +++ b/.github/README.md @@ -26,7 +26,7 @@ RapidRAW is a free, open-source raw editor by [Timon Käch (CyberTimon)](https:/ - **Faster culling:** rate with 0–5 and jump straight to the next photo, and filter for exactly N stars. - **Bring your Lightroom edits along:** import XMP sidecars -38 improvements on top of RapidRAW so far, including fixes for 17 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). +39 improvements on top of RapidRAW so far, including fixes for 17 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). ## Why RapidRoom diff --git a/CHANGES.md b/CHANGES.md index 6af8a2f85..8f0e2189a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,7 +4,7 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber Generated from [rapidroom/changes.json](rapidroom/changes.json) by `node rapidroom/status.mjs`; don't edit by hand. -**38 changes on top of RapidRAW.** 18 fix upstream issues that had been open a median of 68 days when RapidRoom shipped the fix; 17 of them still open upstream. 13 offered upstream as PRs, 3 merged so far. +**39 changes on top of RapidRAW.** 18 fix upstream issues that had been open a median of 68 days when RapidRoom shipped the fix; 17 of them still open upstream. 13 offered upstream as PRs, 3 merged so far. | Change | Type | By | Upstream | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -49,6 +49,7 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber | Linux release packaging: a .deb and an AppImage named RapidRoom that install side by side with RapidRAW | platform | [@yojen7](https://github.com/yojen7) | not yet offered | | Denoise dialogs stay open on busy backdrop clicks; Cancel stops waiting and discards the eventual UI result | fix | [@yojen7](https://github.com/yojen7) | [#1697](https://github.com/CyberTimon/RapidRAW/issues/1697) open 28 d | | DxO compressed DNG highlights no longer wrap to black dots when lookup-table dithering exceeds 16 bits ⚑ | fix | [@yojen7](https://github.com/yojen7) | [#1119](https://github.com/CyberTimon/RapidRAW/issues/1119) open 156 d | +| Color Grading and Color Mixer get their own visibility eye, globally and in masks; the Color panel eye still bypasses every colour tool | feature | [@lalibertemarc](https://github.com/lalibertemarc) | PR [#1833](https://github.com/CyberTimon/RapidRAW/issues/1833) | ⚑ changes rendered output on purpose. Upstream status as of 2026-10-04. @@ -383,3 +384,12 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber - **Upstream:** [#1119](https://github.com/CyberTimon/RapidRAW/issues/1119) open 156 d - **Commits:** [5a44585](https://github.com/yojen7/RapidRAW-DngLab/commit/5a44585da25c7ff783c83a4bff25281f0cf28159), [4c96062](https://github.com/yojen7/RapidRAW-DngLab/commit/4c960626978287ab8e4aabe70a6cfa60b4582273) - **Notes:** Dither values above 65535 now saturate instead of wrapping. Unsaturated values and the random-state update are preserved. The fork retains Kheil-Z's Sony/Canon default-crop fixes; the extra tile-fixture change is test-only. + +### Color Grading and Color Mixer get their own visibility eye, globally and in masks; the Color panel eye still bypasses every colour tool + +- **Type:** feature +- **Landed in RapidRoom:** 2026-10-04 +- **By:** [@lalibertemarc](https://github.com/lalibertemarc), from lalibertemarc/RapidRAW +- **Upstream:** PR [#1833](https://github.com/CyberTimon/RapidRAW/issues/1833) +- **Commits:** [879b414](https://github.com/lalibertemarc/RapidRAW/commit/879b4146fa4d87ecfa3580261cfc0554ffb1555f) +- **Notes:** New sectionVisibility.colorGrading/colorMixer keys default to on, so existing sidecars and presets render unchanged; turning a tool off intentionally skips it. Resetting or pasting the Color section re-enables both tools. diff --git a/CREDITS.md b/CREDITS.md index 3b2ef11a0..a3bd78001 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -26,6 +26,7 @@ RapidRoom is built on other people's work. This file lists where every non-upstr | Lightroom/Camera Raw XMP sidecar import | [@dimafa](https://github.com/dimafa), StephenMasseur | [#1465](https://github.com/CyberTimon/RapidRAW/pull/1465): [7a6913f](https://github.com/dimafa/RapidRAW/commit/7a6913f563a7ed6a3e9493d8f49471622c8caf76), [b3d0c52](https://github.com/dimafa/RapidRAW/commit/b3d0c52472c77b7d93c7774ec663df0341a6a772); builds on [#1280](https://github.com/CyberTimon/RapidRAW/pull/1280) | | Keep pending window/DPI preview resizes | [@subbajeu](https://github.com/subbajeu), adapted by @yojen7 with Codex | [1084ca9](https://github.com/SandeepSubba/RapidRAW/commit/1084ca96fbad8ea1c64dd58d6a26df06fdf3df60) in SandeepSubba/RapidRAW | | Render caches keyed by virtual image identity | [@mlauziertr](https://github.com/mlauziertr) (Maxime Lauzier), adapted by @yojen7 with Codex | [8ec31d4](https://github.com/mlauziertr/picportal-editor/commit/8ec31d4f4fcddc50b6217d9d6419d7278d2937ec) | +| Separate visibility toggles for Color Grading and Color Mixer | [@lalibertemarc](https://github.com/lalibertemarc) with Claude | [lalibertemarc/RapidRAW@879b414](https://github.com/lalibertemarc/RapidRAW/commit/879b4146fa4d87ecfa3580261cfc0554ffb1555f) (upstream PR `CyberTimon/RapidRAW#1833`) | ## RapidRoom changes diff --git a/rapidroom/changes.json b/rapidroom/changes.json index 4e2919672..9c50075d1 100644 --- a/rapidroom/changes.json +++ b/rapidroom/changes.json @@ -525,6 +525,19 @@ "upstream_prs": [], "rendering_change": true, "notes": "Dither values above 65535 now saturate instead of wrapping. Unsaturated values and the random-state update are preserved. The fork retains Kheil-Z's Sony/Canon default-crop fixes; the extra tile-fixture change is test-only." + }, + { + "id": "color-tool-visibility", + "title": "Color Grading and Color Mixer get their own visibility eye, globally and in masks; the Color panel eye still bypasses every colour tool", + "category": "feature", + "landed": "2026-10-04", + "authors": ["lalibertemarc"], + "source": "lalibertemarc/RapidRAW", + "commits": ["https://github.com/lalibertemarc/RapidRAW/commit/879b4146fa4d87ecfa3580261cfc0554ffb1555f"], + "upstream_issues": [], + "upstream_prs": ["CyberTimon/RapidRAW#1833"], + "rendering_change": false, + "notes": "New sectionVisibility.colorGrading/colorMixer keys default to on, so existing sidecars and presets render unchanged; turning a tool off intentionally skips it. Resetting or pasting the Color section re-enables both tools." } ] } From a7825a5bf7faa52a519ad1ae63effb07155d896d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:35:25 +0000 Subject: [PATCH 4/7] Test colour tool visibility: absent keys, independent tools, parent eye Absent or partial sectionVisibility must produce byte-identical uniforms to an explicit all-on one, for global and mask adjustments. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SHAmUdMPzKcgwDMEftFCrN --- src-tauri/src/image_processing.rs | 89 +++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 5bfc18100..7be0c84a1 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -3743,3 +3743,92 @@ mod white_balance_sample_tests { assert!(s.temperature.abs() < 1e-4); } } + +#[cfg(test)] +mod color_tool_visibility_tests { + use super::*; + use serde_json::json; + + fn edited(visibility: Option) -> serde_json::Value { + let mut adj = json!({ + "saturation": 20, + "colorGrading": { + "shadows": { "hue": 200, "saturation": 40, "luminance": 0 }, + "midtones": { "hue": 30, "saturation": 10, "luminance": 5 }, + "highlights": { "hue": 60, "saturation": 25, "luminance": 0 }, + "global": { "hue": 0, "saturation": 0, "luminance": 0 }, + "blending": 70, + "balance": 15 + }, + "hsl": { "reds": { "hue": 10, "saturation": -20, "luminance": 5 } } + }); + if let Some(v) = visibility { + adj["sectionVisibility"] = v; + } + adj + } + + fn global(adj: &serde_json::Value) -> GlobalAdjustments { + get_global_adjustments_from_json(adj, true, None) + } + + fn has_grading(a: &GlobalAdjustments) -> bool { + a.color_grading_shadows.saturation != 0.0 && a.color_grading_balance != 0.0 + } + + fn has_mixer(a: &GlobalAdjustments) -> bool { + a.hsl[0].hue != 0.0 + } + + #[test] + fn absent_tool_keys_render_like_all_visible() { + let all_on = global(&edited(Some(json!({ + "basic": true, "curves": true, "color": true, + "colorGrading": true, "colorMixer": true, "details": true, "effects": true + })))); + for vis in [None, Some(json!({})), Some(json!({ "color": true }))] { + let a = global(&edited(vis)); + assert_eq!(bytemuck::bytes_of(&a), bytemuck::bytes_of(&all_on)); + } + assert!(has_grading(&all_on) && has_mixer(&all_on)); + } + + #[test] + fn tools_toggle_independently() { + let no_grading = global(&edited(Some(json!({ "colorGrading": false })))); + assert!(!has_grading(&no_grading) && has_mixer(&no_grading)); + assert_eq!(no_grading.color_grading_blending, 0.5); + + let no_mixer = global(&edited(Some(json!({ "colorMixer": false })))); + assert!(has_grading(&no_mixer) && !has_mixer(&no_mixer)); + assert!(no_mixer.saturation != 0.0); + } + + #[test] + fn color_panel_eye_bypasses_both_tools() { + let a = global(&edited(Some(json!({ + "color": false, "colorGrading": true, "colorMixer": true + })))); + assert!(!has_grading(&a) && !has_mixer(&a)); + assert_eq!(a.saturation, 0.0); + } + + #[test] + fn mask_tools_follow_the_same_rules() { + let on = get_mask_adjustments_from_json(&edited(None)); + let explicit = get_mask_adjustments_from_json(&edited(Some(json!({ "color": true })))); + assert_eq!(bytemuck::bytes_of(&on), bytemuck::bytes_of(&explicit)); + assert!(on.color_grading_shadows.saturation != 0.0 && on.hsl[0].hue != 0.0); + + let no_grading = + get_mask_adjustments_from_json(&edited(Some(json!({ "colorGrading": false })))); + assert!(no_grading.color_grading_shadows.saturation == 0.0 && no_grading.hsl[0].hue != 0.0); + + let no_mixer = + get_mask_adjustments_from_json(&edited(Some(json!({ "colorMixer": false })))); + assert!(no_mixer.color_grading_shadows.saturation != 0.0 && no_mixer.hsl[0].hue == 0.0); + + let parent_off = get_mask_adjustments_from_json(&edited(Some(json!({ "color": false })))); + assert!(parent_off.color_grading_shadows.saturation == 0.0 && parent_off.hsl[0].hue == 0.0); + } +} From 504e8b2f41327ac2254156ba382890a5202a17c1 Mon Sep 17 00:00:00 2001 From: Yojen <86065973+yojen7@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:04:48 -1000 Subject: [PATCH 5/7] Preview catalog photo edits before selective import (#77) Adapt Laurensius Adi's develop-row source using the existing read-only catalog/path resolver, bounded Lua parser and image-aware XMP mapper. Add explicit per-photo selection, independent edit/rating replacement, stale-preview checks, atomic writes and stable virtual-copy IDs. Preserve unrelated metadata and reject unreadable edits and Card paths. (from laurensiusadi/RapidRAW@dda6cc51c69dc6a17906609dbb667eff3507aeeb) Omit the original second catalog reader, line-based adjustment mapping, folder-median white-balance estimates, default-sharpening heuristic, truncated virtual IDs and unreviewed writes. Reuse the current mapper for supported nested profile luma curves; report unsupported profile/mask data. Written and reviewed with Codex. Native/frontend fixtures cover read-only preview, preservation/replacement, repeat/stale/partial failure and Card mode. Real catalog and desktop/platform checks remain unavailable. Co-authored-by: Laurensius Adi --- .github/README.md | 2 +- CHANGES.md | 12 +- CREDITS.md | 2 + rapidroom/changes.json | 13 + .../validation/catalog-develop/README.md | 51 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 2 + src-tauri/src/lightroom/catalog.rs | 2 +- src-tauri/src/lightroom/develop.rs | 1065 +++++++++++++++++ src-tauri/src/lightroom/mod.rs | 1 + src-tauri/src/lrtemplate.rs | 187 ++- src-tauri/src/preset_converter.rs | 134 ++- src/components/modals/AppModals.tsx | 1 + .../modals/LightroomDevelopImport.test.tsx | 173 +++ .../modals/LightroomDevelopImport.tsx | 299 +++++ .../modals/LightroomImportModal.test.tsx | 32 +- .../modals/LightroomImportModal.tsx | 121 +- src/components/ui/AppProperties.tsx | 2 + src/i18n/locales/en.json | 44 +- 20 files changed, 2071 insertions(+), 74 deletions(-) create mode 100644 rapidroom/validation/catalog-develop/README.md create mode 100644 src-tauri/src/lightroom/develop.rs create mode 100644 src/components/modals/LightroomDevelopImport.test.tsx create mode 100644 src/components/modals/LightroomDevelopImport.tsx diff --git a/.github/README.md b/.github/README.md index c155f8929..ab57e1b39 100644 --- a/.github/README.md +++ b/.github/README.md @@ -31,7 +31,7 @@ RapidRAW is a free, open-source raw editor by [Timon Käch (CyberTimon)](https:/ - **Pick and reject flags for culling,** separate from stars: P, X and U as in Lightroom, a flag filter, and rejects that darktable, Bridge and Lightroom understand. - **Presets at your fingertips.** Presets sit in the left sidebar as compact rows: hover to preview one on your photo, click to apply, and star favorites to pin them on top. -52 improvements on top of RapidRAW so far, including fixes for 18 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). +53 improvements on top of RapidRAW so far, including fixes for 18 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). ## Why RapidRoom diff --git a/CHANGES.md b/CHANGES.md index 2a3015df3..868ae9a00 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,10 +4,11 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber Generated from [rapidroom/changes.json](rapidroom/changes.json) by `node rapidroom/status.mjs`; don't edit by hand. -**52 changes on top of RapidRAW.** 19 fix upstream issues that had been open a median of 63 days when RapidRoom shipped the fix; 18 of them still open upstream. 14 offered upstream as PRs, 3 merged so far. +**53 changes on top of RapidRAW.** 19 fix upstream issues that had been open a median of 63 days when RapidRoom shipped the fix; 18 of them still open upstream. 14 offered upstream as PRs, 3 merged so far. | Change | Type | By | Upstream | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Preview and selectively import Lightroom catalog photo edits, crop, straighten, rotation, star ratings and virtual copies, with unsupported-setting reports and independent replacement choices | feature | [@laurensiusadi](https://github.com/laurensiusadi), [@yojen7](https://github.com/yojen7) | not yet offered | | Import Lightroom Classic collections and collection sets from a .lrcat catalog as albums, with a preview of found and missing photos and relinking of moved root folders | feature | [@Bennyyy27](https://github.com/Bennyyy27) | not yet offered | | Faster raw decoding: highlight recovery and pixel-format conversions run on all CPU cores; the GPU adapter is logged; `rapidraw bench` measures the image pipeline without a window | performance | [@elhigu](https://github.com/elhigu) | PR [#1790](https://github.com/CyberTimon/RapidRAW/pull/1790) open | | Batch rename treats a photo as one unit: RAW+JPEG/HEIF pairs and every sidecar (.rrdata incl. virtual copies, both XMP styles, .acr, .dop, .pp3) move together, with metadata, burst and group tokens, a preview with collision checks, a rollback-safe two-phase rename and undo | feature | [@yojen7](https://github.com/yojen7), [@SandeepSubba](https://github.com/SandeepSubba) | PR [#1309](https://github.com/CyberTimon/RapidRAW/issues/1309) | @@ -68,6 +69,15 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber ## Details +### Preview and selectively import Lightroom catalog photo edits, crop, straighten, rotation, star ratings and virtual copies, with unsupported-setting reports and independent replacement choices + +- **Type:** feature +- **Landed in RapidRoom:** 2026-10-04 +- **By:** [@laurensiusadi](https://github.com/laurensiusadi), [@yojen7](https://github.com/yojen7), from laurensiusadi/RapidRAW, adapted in RapidRoom with the shared read-only catalog reader and current image-aware XMP mapper +- **Upstream:** not yet offered +- **Commits:** [dda6cc5](https://github.com/laurensiusadi/RapidRAW/commit/dda6cc51c69dc6a17906609dbb667eff3507aeeb) +- **Notes:** Catalogs and originals are read only. Reuses the collections root resolver and bounded Lua-table parser; caps compressed rows at 4 MiB. Preview lists converted controls, unsupported settings, missing originals and unreadable sidecars before an explicit selection is applied. Existing edits, cleared ratings, keywords, EXIF and other metadata are preserved unless the relevant replacement choice is enabled. Uses atomic sidecars, Card mode guards, stale-preview checks and stable full-ID virtual-copy identities. As-shot white balance requires an explicit history reference; no folder median is estimated. Supported nested profile tone curves use the existing mapper; Adobe profiles, local/AI masks and other unsupported fields are reported. Synthetic native/frontend coverage and private sidecar-derived synthetic-catalog calibration only; real Lightroom catalogs, desktop interaction and other platforms still need human checks. Adobe rendering parity is not promised. Adapted with Codex, with source authorship retained. + ### Import Lightroom Classic collections and collection sets from a .lrcat catalog as albums, with a preview of found and missing photos and relinking of moved root folders - **Type:** feature diff --git a/CREDITS.md b/CREDITS.md index 83cf4f444..c5a282ae7 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -92,6 +92,8 @@ RapidRoom is built on other people's work. This file lists where every non-upstr | Pick and reject flags | [@lalibertemarc](https://github.com/lalibertemarc), adapted by @yojen7 with Claude Code | [lalibertemarc/RapidRAW@1b4b9db](https://github.com/lalibertemarc/RapidRAW/commit/1b4b9db115f0c1aa6f12e2b4bc0515ffb7fb0eb6) | | Presets sidebar with hover previews, favorites, folder reordering | [@laurensiusadi](https://github.com/laurensiusadi) (Laurensius Adi), adapted by Claude Code | [9535884](https://github.com/laurensiusadi/RapidRAW/commit/953588443099def3fd0c5e92a55e1966d994e385), [9e478b6](https://github.com/laurensiusadi/RapidRAW/commit/9e478b66eeee63be3e79238af708916655f52125) in laurensiusadi/RapidRAW | +| Lightroom catalog photo-edit previews and selective develop/virtual-copy import | [@laurensiusadi](https://github.com/laurensiusadi) (Laurensius Adi), adapted by yojen7 with Codex | [laurensiusadi/RapidRAW@dda6cc5](https://github.com/laurensiusadi/RapidRAW/commit/dda6cc51c69dc6a17906609dbb667eff3507aeeb); shares the existing catalog reader and XMP mapper | + ## RapidRoom changes | Change | Author | Upstream PR | diff --git a/rapidroom/changes.json b/rapidroom/changes.json index ea7ca985a..475e7a030 100644 --- a/rapidroom/changes.json +++ b/rapidroom/changes.json @@ -1,6 +1,19 @@ { "$comment": "Everything RapidRoom adds on top of upstream RapidRAW. Edit this file in the same PR as the change; `node rapidroom/status.mjs` renders the README table and CHANGES.md. Fields: id, title, category (fix | feature | performance | privacy | platform | ci), landed (YYYY-MM-DD, date it reached RapidRoom main), authors (GitHub logins), source (rapidroom, or the fork it was harvested from), commits (URLs), upstream_issues and upstream_prs ('owner/repo#n'), rendering_change, notes, highlight (optional: a short, user-facing Markdown line; entries with one are listed under Highlights in the README, everything is in CHANGES.md).", "changes": [ + { + "id": "lightroom-catalog-develop-import", + "title": "Preview and selectively import Lightroom catalog photo edits, crop, straighten, rotation, star ratings and virtual copies, with unsupported-setting reports and independent replacement choices", + "category": "feature", + "landed": "2026-10-04", + "authors": ["laurensiusadi", "yojen7"], + "source": "laurensiusadi/RapidRAW, adapted in RapidRoom with the shared read-only catalog reader and current image-aware XMP mapper", + "commits": ["https://github.com/laurensiusadi/RapidRAW/commit/dda6cc51c69dc6a17906609dbb667eff3507aeeb"], + "upstream_issues": [], + "upstream_prs": [], + "rendering_change": false, + "notes": "Catalogs and originals are read only. Reuses the collections root resolver and bounded Lua-table parser; caps compressed rows at 4 MiB. Preview lists converted controls, unsupported settings, missing originals and unreadable sidecars before an explicit selection is applied. Existing edits, cleared ratings, keywords, EXIF and other metadata are preserved unless the relevant replacement choice is enabled. Uses atomic sidecars, Card mode guards, stale-preview checks and stable full-ID virtual-copy identities. As-shot white balance requires an explicit history reference; no folder median is estimated. Supported nested profile tone curves use the existing mapper; Adobe profiles, local/AI masks and other unsupported fields are reported. Synthetic native/frontend coverage and private sidecar-derived synthetic-catalog calibration only; real Lightroom catalogs, desktop interaction and other platforms still need human checks. Adobe rendering parity is not promised. Adapted with Codex, with source authorship retained." + }, { "id": "lightroom-collections-import", "title": "Import Lightroom Classic collections and collection sets from a .lrcat catalog as albums, with a preview of found and missing photos and relinking of moved root folders", diff --git a/rapidroom/validation/catalog-develop/README.md b/rapidroom/validation/catalog-develop/README.md new file mode 100644 index 000000000..322f617bd --- /dev/null +++ b/rapidroom/validation/catalog-develop/README.md @@ -0,0 +1,51 @@ +# Catalog photo-edit import + +The source is Laurensius Adi's catalog importer at +[dda6cc51c6](https://github.com/laurensiusadi/RapidRAW/commit/dda6cc51c69dc6a17906609dbb667eff3507aeeb). +The RapidRoom adaptation uses the existing read-only collections catalog reader, +root relinker, bounded Lua-table parser and image-aware XMP converter. + +Open **Import Lightroom Catalog**, review the shared root-folder mappings, then +choose **Photo edits**. Review each photo's converted controls and unsupported +settings. New readable photos are selected initially; existing sidecars are not. +Choose photos explicitly and import. Saved edits and ratings have independent +replacement choices. A new virtual copy inherits unrelated master metadata +without changing the master's sidecar. Repeating the import identifies the same +copy and preserves its existing edits by default. + +Use a closed or safely copied catalog. The existing catalog reader rejects +active lock files and snapshots a readable WAL database without modifying it. +Rows are limited to 4 MiB before and after decompression. Unknown schema, missing +photos, malformed settings and unreadable saved edits are reported. Catalog, +photo and sidecar changes invalidate the preview. Atomic writes preserve +unrelated metadata and report individual write failures; the batch is not a +multi-file transaction. Card mode remains read only. + +White balance uses explicit as-shot history when available, never a folder +median. Crop and straighten use the current converter. Catalog rotations are +supported relative to source EXIF for non-mirrored orientations; unknown or +mirrored geometry is omitted and reported. Nested profile luma curves use the +existing composition mapper, but profile color/LUTs, local and AI masks, virtual +copy titles and other unsupported settings are shown as not transferred. No +Adobe rendering parity is promised. + +## Verification and limits + +Native fixtures cover read-only previews, selection, preserved metadata and +cleared ratings, independent replacement, stale previews, repeated virtual +copies with formerly colliding IDs, compressed and malformed rows, portrait +crop/rotation, unsupported fields, Card mode and write failures. Frontend tests +exercise the two import tabs, folder relinking, default selection, independent +replacement choices, fresh fingerprints after errors, partial reports and Card +mode. The regular 60-render corpus checks unchanged rendering behavior. + +No real Lightroom catalog was available locally. Private calibration uses a +closed **synthetic catalog with global settings derived from a real Lightroom +sidecar**, on a copied portrait RAW and its Lightroom reference export. This +checks the shared mapper and rendered result, not Adobe's real catalog schema. +All private artifacts stay local and are excluded from this repository. + +A human still needs to test a real closed catalog in the desktop app, relinking +from another machine, the per-photo review and replacement flow, virtual-copy +visibility after import/restart, unsupported reports and appearance against +Lightroom, plus macOS and Windows behavior. Those checks belong in issue #63. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bc957d4cb..4c5f91bd2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -13,6 +13,7 @@ dependencies = [ "chrono", "fern", "filetime", + "flate2", "futures", "fuzzy-matcher", "glam 0.33.5", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index da4482c46..ed386f740 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -70,6 +70,7 @@ rgb = "0.8.53" imgref = "1.12.2" sysinfo = "0.39.6" rusqlite = { version = "0.40.2", features = ["bundled"] } +flate2 = "=1.1.9" [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] trash = "5.2.6" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e9ae33c6..968509e69 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2396,6 +2396,8 @@ pub fn run() { file_management::get_album_images, lightroom::collections::preview_lightroom_collections, lightroom::collections::import_lightroom_collections, + lightroom::develop::preview_lightroom_develop, + lightroom::develop::import_lightroom_develop, tagging::start_background_indexing, tagging::clear_ai_tags, tagging::clear_all_tags, diff --git a/src-tauri/src/lightroom/catalog.rs b/src-tauri/src/lightroom/catalog.rs index e1968311f..f74f58b95 100644 --- a/src-tauri/src/lightroom/catalog.rs +++ b/src-tauri/src/lightroom/catalog.rs @@ -153,7 +153,7 @@ impl Catalog { } } - fn has_column(&self, table: &str, column: &str) -> bool { + pub(crate) fn has_column(&self, table: &str, column: &str) -> bool { self.connection .prepare(&format!("SELECT {column} FROM {table} LIMIT 0")) .is_ok() diff --git a/src-tauri/src/lightroom/develop.rs b/src-tauri/src/lightroom/develop.rs new file mode 100644 index 000000000..5f0c251de --- /dev/null +++ b/src-tauri/src/lightroom/develop.rs @@ -0,0 +1,1065 @@ +//! Catalog develop settings are adapted from Laurensius Adi's importer +//! (laurensiusadi/RapidRAW@dda6cc51c69dc6a17906609dbb667eff3507aeeb). +//! Catalog access/path resolution and adjustment mapping remain shared with +//! the existing collections importer and image-aware XMP mapper. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use rusqlite::types::ValueRef; +use serde::Serialize; +use serde_json::{Value, json}; +use uuid::Uuid; + +use super::catalog::{Catalog, text}; +use crate::file_management::{is_card_read_only_path, parse_virtual_path, write_file_atomically}; +use crate::image_processing::ImageMetadata; +use crate::lrtemplate::{catalog_as_shot_white_balance, catalog_develop_to_xmp}; +use crate::preset_converter::{ + convert_xmp_sidecar_to_preset_for_image, extract_namespaced_scalar, + lightroom_settings_not_transferred, +}; + +const MAX_TEXT_BYTES: usize = 4 * 1024 * 1024; +const MAX_ROWS: usize = 50_000; +static IMPORT_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DevelopPhotoPreview { + pub id: i64, + pub path: String, + pub copy_name: String, + pub virtual_copy: bool, + pub found: bool, + pub existing_sidecar: bool, + pub existing_edits: bool, + pub rating: Option, + pub adjustments: Value, + pub unsupported: Vec, + pub error: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DevelopPreview { + pub catalog_name: String, + pub fingerprint: String, + pub photos: Vec, +} + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DevelopImportResult { + pub imported: usize, + pub preserved: usize, + pub virtual_copies: usize, + pub errors: Vec, +} + +struct DevelopRow { + id: i64, + master: Option, + rating: Option, + orientation: String, + copy_name: String, + settings: Result, + width: Option, + height: Option, + xmp: String, +} + +struct PreparedPhoto { + preview: DevelopPhotoPreview, + sidecar: PathBuf, + sidecar_bytes: Option>, + document: Value, + inherit_from: Option<(PathBuf, Vec)>, + raw_identity: Option<(u64, u128)>, +} + +struct PreparedImport { + name: String, + fingerprint: String, + photos: Vec, +} + +fn decode_text(bytes: &[u8]) -> Result { + if bytes.len() > MAX_TEXT_BYTES { + return Err("Develop settings exceed the supported row size.".into()); + } + if let Ok(text) = std::str::from_utf8(bytes) + && text + .trim_start_matches('\u{feff}') + .trim_start() + .starts_with(['s', '<']) + { + return Ok(text.into()); + } + if bytes.len() <= 4 { + return Err("Develop settings are empty or unreadable.".into()); + } + let decoder = flate2::read::ZlibDecoder::new(&bytes[4..]); + let mut decoded = Vec::new(); + decoder + .take(MAX_TEXT_BYTES as u64 + 1) + .read_to_end(&mut decoded) + .map_err(|_| "Compressed develop settings could not be read.".to_string())?; + if decoded.len() > MAX_TEXT_BYTES { + return Err("Expanded develop settings exceed the supported row size.".into()); + } + String::from_utf8(decoded).map_err(|_| "Develop settings are not valid UTF-8.".into()) +} + +fn column(catalog: &Catalog, table: &str, alias: &str, name: &str) -> String { + if catalog.has_column(table, name) { + format!("{alias}.{name}") + } else { + "NULL".into() + } +} + +fn read_rows(catalog: &Catalog) -> Result, String> { + catalog.require_tables(&["Adobe_imageDevelopSettings"])?; + for (table, columns) in [ + ("Adobe_images", &["id_local"][..]), + ("Adobe_imageDevelopSettings", &["image", "text"][..]), + ] { + for name in columns { + if !catalog.has_column(table, name) { + return Err(format!("This catalog has no {table}.{name} column.")); + } + } + } + let additional = catalog.has_column("Adobe_AdditionalMetadata", "image") + && catalog.has_column("Adobe_AdditionalMetadata", "xmp"); + let query = format!( + "SELECT i.id_local, {}, {}, {}, {}, d.text, {}, {}, {} FROM Adobe_images i + JOIN Adobe_imageDevelopSettings d ON d.image = i.id_local {} ORDER BY i.id_local", + column(catalog, "Adobe_images", "i", "masterImage"), + column(catalog, "Adobe_images", "i", "rating"), + column(catalog, "Adobe_images", "i", "orientation"), + column(catalog, "Adobe_images", "i", "copyName"), + column(catalog, "Adobe_imageDevelopSettings", "d", "fileWidth"), + column(catalog, "Adobe_imageDevelopSettings", "d", "fileHeight"), + if additional { "m.xmp" } else { "NULL" }, + if additional { + "LEFT JOIN Adobe_AdditionalMetadata m ON m.image = i.id_local" + } else { + "" + }, + ); + let mut statement = catalog + .connection() + .prepare(&query) + .map_err(|e| e.to_string())?; + let mut rows = statement.query([]).map_err(|e| e.to_string())?; + let mut output = Vec::new(); + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + if output.len() >= MAX_ROWS { + return Err( + "This catalog has more than 50,000 develop rows; import a smaller catalog.".into(), + ); + } + let bytes = match row.get_ref(5).map_err(|e| e.to_string())? { + ValueRef::Text(bytes) | ValueRef::Blob(bytes) => bytes, + _ => &[], + }; + let rating = text(row, 2) + .map_err(|e| e.to_string())? + .parse::() + .ok() + .filter(|value| { + value.is_finite() && value.fract() == 0.0 && (0.0..=5.0).contains(value) + }) + .map(|value| value as u8); + let positive_number = |index| -> Option { + text(row, index) + .ok()? + .parse::() + .ok() + .filter(|v| v.is_finite() && *v >= 1.0) + }; + output.push(DevelopRow { + id: row.get(0).map_err(|e| e.to_string())?, + master: text(row, 1) + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0), + rating, + orientation: text(row, 3).map_err(|e| e.to_string())?, + copy_name: text(row, 4).map_err(|e| e.to_string())?, + settings: decode_text(bytes), + width: positive_number(6), + height: positive_number(7), + xmp: match row.get_ref(8).map_err(|e| e.to_string())? { + ValueRef::Text(bytes) | ValueRef::Blob(bytes) => { + decode_text(bytes).unwrap_or_default() + } + _ => String::new(), + }, + }); + } + Ok(output) +} + +fn history_white_balance(catalog: &Catalog, id: i64) -> Option<(f64, f64)> { + if !catalog.has_column("Adobe_libraryImageDevelopHistoryStep", "image") + || !catalog.has_column("Adobe_libraryImageDevelopHistoryStep", "text") + { + return None; + } + let order = if catalog.has_column("Adobe_libraryImageDevelopHistoryStep", "dateCreated") { + " ORDER BY dateCreated" + } else { + "" + }; + let mut statement = catalog + .connection() + .prepare(&format!( + "SELECT text FROM Adobe_libraryImageDevelopHistoryStep WHERE image = ?1{order}" + )) + .ok()?; + let mut rows = statement.query([id]).ok()?; + while let Some(row) = rows.next().ok()? { + let bytes = match row.get_ref(0).ok()? { + ValueRef::Text(bytes) | ValueRef::Blob(bytes) => bytes, + _ => continue, + }; + if let Ok(text) = decode_text(bytes) + && let Ok(Some(white_balance)) = catalog_as_shot_white_balance(&text) + { + return Some(white_balance); + } + } + None +} + +fn rotation_steps(orientation: u16) -> Option { + match orientation { + 1 => Some(0), + 6 => Some(1), + 3 => Some(2), + 8 => Some(3), + _ => None, + } +} + +fn source_orientation(row: &DevelopRow, path: &Path) -> Option { + if let Some(value) = extract_namespaced_scalar(&row.xmp, "tiff", "Orientation") + .and_then(|v| v.parse::().ok()) + .filter(|v| (1..=8).contains(v)) + { + return Some(value); + } + let file = fs::File::open(path).ok()?; + let metadata = exif::Reader::new() + .read_from_container(&mut BufReader::new(file)) + .ok()?; + let value = metadata + .get_field(exif::Tag::Orientation, exif::In::PRIMARY) + .and_then(|v| v.value.get_uint(0)) + .unwrap_or(1); + (1..=8).contains(&value).then_some(value as u16) +} + +fn map_adjustments( + catalog: &Catalog, + row: &DevelopRow, + path: &Path, +) -> Result<(Value, Vec), String> { + let settings = row.settings.as_ref().map_err(Clone::clone)?; + let as_shot = history_white_balance(catalog, row.id) + .or_else(|| row.master.and_then(|id| history_white_balance(catalog, id))); + let converted = catalog_develop_to_xmp(settings, as_shot)?; + let mut xmp = converted.xmp; + let target_orientation = match row.orientation.as_str() { + "AB" => Some(1), + "BC" => Some(6), + "CD" => Some(3), + "DA" => Some(8), + _ => None, + }; + let source_steps = source_orientation(row, path).and_then(rotation_steps); + let target_steps = target_orientation.and_then(rotation_steps); + let geometry_supported = source_steps.is_some() && target_steps.is_some(); + let dimensions = match (row.width, row.height) { + (Some(width), Some(height)) => { + format!(" tiff:ImageWidth=\"{width}\" tiff:ImageLength=\"{height}\"") + } + _ => String::new(), + }; + if let Some(orientation) = target_orientation { + xmp = xmp.replacen(" String { + let path_text = path.to_string_lossy(); + if row.master.is_none() { + return path_text.into_owned(); + } + let identity = catalog + .path() + .canonicalize() + .unwrap_or_else(|_| catalog.path().to_path_buf()); + let hash = + blake3::hash(format!("lightroom:{}:{}", identity.to_string_lossy(), row.id).as_bytes()); + let id = Uuid::from_bytes(hash.as_bytes()[..16].try_into().expect("hash is 32 bytes")); + format!("{path_text}?vc={id}") +} + +fn sidecar_bytes(path: &Path) -> Result>, String> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("Could not read existing edits: {error}")), + } +} + +fn file_identity(path: &Path) -> Option<(u64, u128)> { + let metadata = fs::metadata(path).ok()?; + Some(( + metadata.len(), + metadata + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_nanos(), + )) +} + +fn hash_field(hash: &mut blake3::Hasher, bytes: &[u8]) { + hash.update(&(bytes.len() as u64).to_le_bytes()); + hash.update(bytes); +} + +fn hash_file(hash: &mut blake3::Hasher, path: &Path) -> Result<(), String> { + hash_field(hash, path.to_string_lossy().as_bytes()); + let mut file = match fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + hash_field(hash, b"missing"); + return Ok(()); + } + Err(error) => return Err(format!("Could not inspect catalog: {error}")), + }; + let mut contents = blake3::Hasher::new(); + let mut buffer = [0u8; 65536]; + loop { + let count = file.read(&mut buffer).map_err(|e| e.to_string())?; + if count == 0 { + break; + } + contents.update(&buffer[..count]); + } + hash_field(hash, contents.finalize().as_bytes()); + Ok(()) +} + +fn prepare(path: &Path, mappings: &HashMap) -> Result { + let catalog = Catalog::open(path)?; + let roots = catalog.resolve_roots(mappings)?; + let locations = catalog.image_locations()?; + let rows = read_rows(&catalog)?; + let mut hash = blake3::Hasher::new(); + let ordered: BTreeMap<_, _> = mappings.iter().collect(); + hash_field( + &mut hash, + &serde_json::to_vec(&ordered).map_err(|e| e.to_string())?, + ); + for suffix in ["", "-wal", "-journal"] { + let mut file = path.as_os_str().to_owned(); + file.push(suffix); + hash_file(&mut hash, Path::new(&file))?; + } + let mut photos = Vec::new(); + for row in rows { + let source = locations + .get(&row.id) + .or_else(|| row.master.and_then(|id| locations.get(&id))) + .and_then(|location| roots.file_path(location)); + let path = source.as_deref().unwrap_or_else(|| Path::new("")); + let virtual_path = virtual_path(&catalog, &row, path); + let (_, sidecar) = parse_virtual_path(&virtual_path); + let identity = file_identity(path); + let found = path.is_file() && identity.is_some(); + let mut error = (!found).then(|| { + "The original photo could not be found. Relink its root folder first.".to_string() + }); + let bytes = match sidecar_bytes(&sidecar) { + Ok(bytes) => bytes, + Err(message) => { + error = Some(message); + None + } + }; + let mut inherited = None; + let mut document = + serde_json::to_value(ImageMetadata::default()).map_err(|e| e.to_string())?; + let mut metadata = ImageMetadata::default(); + let existing = bytes.is_some(); + let document_bytes = if existing { + bytes.as_deref() + } else if row.master.is_some() && found { + let (_, physical_sidecar) = parse_virtual_path(&path.to_string_lossy()); + match sidecar_bytes(&physical_sidecar) { + Ok(Some(bytes)) => { + inherited = Some((physical_sidecar, bytes)); + inherited.as_ref().map(|(_, bytes)| bytes.as_slice()) + } + Ok(None) => None, + Err(message) => { + error = Some(message); + None + } + } + } else { + None + }; + if let Some(bytes) = document_bytes { + match serde_json::from_slice::(bytes).and_then(|value| { + serde_json::from_value::(value.clone()) + .map(|metadata| (value, metadata)) + }) { + Ok((value, saved)) => { + document = value; + metadata = saved; + } + Err(_) => { + error = Some( + "Existing edits are unreadable; repair or back them up before importing." + .into(), + ) + } + } + } + let (adjustments, unsupported) = if found { + match map_adjustments(&catalog, &row, path) { + Ok(mapped) => mapped, + Err(message) => { + error = Some(message); + (json!({}), Vec::new()) + } + } + } else { + (json!({}), Vec::new()) + }; + hash_field(&mut hash, &row.id.to_le_bytes()); + hash_field(&mut hash, virtual_path.as_bytes()); + hash_field( + &mut hash, + &serde_json::to_vec(&identity).map_err(|e| e.to_string())?, + ); + hash_field(&mut hash, bytes.as_deref().unwrap_or(b"missing")); + if let Some((path, bytes)) = &inherited { + hash_field(&mut hash, path.to_string_lossy().as_bytes()); + hash_field(&mut hash, bytes); + } + hash_field( + &mut hash, + &serde_json::to_vec(&adjustments).map_err(|e| e.to_string())?, + ); + photos.push(PreparedPhoto { + preview: DevelopPhotoPreview { + id: row.id, + path: virtual_path, + copy_name: row.copy_name, + virtual_copy: row.master.is_some(), + found, + existing_sidecar: existing, + existing_edits: existing + && metadata + .adjustments + .as_object() + .is_some_and(|map| !map.is_empty()), + rating: row.rating, + adjustments, + unsupported, + error, + }, + sidecar, + sidecar_bytes: bytes, + document, + inherit_from: inherited, + raw_identity: identity, + }); + } + let mut destinations: HashMap> = HashMap::new(); + for (index, photo) in photos.iter().enumerate() { + if photo.preview.found { + destinations + .entry(photo.sidecar.clone()) + .or_default() + .push(index); + } + } + for indices in destinations.values().filter(|indices| indices.len() > 1) { + for index in indices { + photos[*index].preview.error = Some( + "Several catalog rows map to the same edit file. Check the root mappings.".into(), + ); + } + } + Ok(PreparedImport { + name: catalog.name(), + fingerprint: hash.finalize().to_hex().to_string(), + photos, + }) +} + +impl PreparedImport { + fn into_preview(self) -> DevelopPreview { + DevelopPreview { + catalog_name: self.name, + fingerprint: self.fingerprint, + photos: self.photos.into_iter().map(|photo| photo.preview).collect(), + } + } +} + +fn apply_import( + path: &Path, + mappings: &HashMap, + fingerprint: &str, + selections: &[i64], + replace_edits: bool, + import_ratings: bool, + replace_ratings: bool, +) -> Result { + let _guard = IMPORT_LOCK + .lock() + .map_err(|_| "Catalog import lock failed.".to_string())?; + let prepared = prepare(path, mappings)?; + if prepared.fingerprint != fingerprint { + return Err("The catalog, photos, or saved edits changed after preview. Refresh the preview before importing.".into()); + } + let selected: HashSet<_> = selections.iter().copied().collect(); + if selected.len() != selections.len() { + return Err("The selection contains duplicate photo IDs.".into()); + } + let known: HashSet<_> = prepared.photos.iter().map(|p| p.preview.id).collect(); + if !selected.is_subset(&known) { + return Err("The selection contains photos outside this preview.".into()); + } + let photos: Vec<_> = prepared + .photos + .into_iter() + .filter(|p| selected.contains(&p.preview.id)) + .collect(); + for photo in &photos { + if let Some(error) = &photo.preview.error { + return Err(format!("{}: {error}", photo.preview.path)); + } + let (source, _) = parse_virtual_path(&photo.preview.path); + if is_card_read_only_path(&source) || is_card_read_only_path(&photo.sidecar) { + return Err( + "Card mode is read-only. Copy the photos to a local folder before importing edits." + .into(), + ); + } + } + let mut result = DevelopImportResult::default(); + for mut photo in photos { + let (source, _) = parse_virtual_path(&photo.preview.path); + let unchanged = sidecar_bytes(&photo.sidecar) + .is_ok_and(|bytes| bytes == photo.sidecar_bytes) + && file_identity(&source) == photo.raw_identity + && photo.inherit_from.as_ref().is_none_or(|(path, bytes)| { + sidecar_bytes(path) + .is_ok_and(|current| current.as_deref() == Some(bytes.as_slice())) + }); + if !unchanged { + result.errors.push(format!( + "{}: saved edits or the original changed; refresh the preview.", + photo.preview.path + )); + continue; + } + let mut changed = false; + if !photo.preview.existing_sidecar || replace_edits { + photo.document["adjustments"] = photo.preview.adjustments; + changed = true; + } + if import_ratings + && (!photo.preview.existing_sidecar || replace_ratings) + && let Some(rating) = photo.preview.rating + { + photo.document["rating"] = json!(rating); + photo.document["rating_is_explicit"] = json!(true); + changed = true; + } + if !changed { + result.preserved += 1; + continue; + } + let bytes = serde_json::to_vec_pretty(&photo.document).map_err(|e| e.to_string())?; + match write_file_atomically(&photo.sidecar, bytes) { + Ok(()) => { + result.imported += 1; + if photo.preview.virtual_copy && !photo.preview.existing_sidecar { + result.virtual_copies += 1; + } + } + Err(error) => result + .errors + .push(format!("{}: {error}", photo.preview.path)), + } + } + Ok(result) +} + +#[tauri::command] +pub async fn preview_lightroom_develop( + path: String, + mappings: HashMap, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + prepare(Path::new(&path), &mappings).map(PreparedImport::into_preview) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn import_lightroom_develop( + path: String, + mappings: HashMap, + fingerprint: String, + selections: Vec, + replace_edits: bool, + import_ratings: bool, + replace_ratings: bool, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + apply_import( + Path::new(&path), + &mappings, + &fingerprint, + &selections, + replace_edits, + import_ratings, + replace_ratings, + ) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::file_management::card_mode_test_support::CardMode; + use crate::lightroom::test_catalog::{TestCatalog, folder_state, touch}; + use rusqlite::params; + use std::io::Write; + + struct Library { + dir: tempfile::TempDir, + catalog: TestCatalog, + path: PathBuf, + photos: PathBuf, + mappings: HashMap, + } + + impl Library { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("source.lrcat"); + let photos = dir.path().join("photos"); + fs::create_dir(&photos).unwrap(); + let catalog = TestCatalog::create(&path); + catalog + .connection + .execute_batch( + "ALTER TABLE Adobe_images ADD COLUMN rating; + ALTER TABLE Adobe_images ADD COLUMN orientation; + CREATE TABLE Adobe_imageDevelopSettings (image, text, fileWidth, fileHeight); + CREATE TABLE Adobe_AdditionalMetadata (image, xmp); + CREATE TABLE Adobe_libraryImageDevelopHistoryStep (image, text, dateCreated);", + ) + .unwrap(); + catalog.root(1, "/old/photos/", None); + catalog.folder(1, 1, ""); + let mappings = + HashMap::from([("/old/photos/".into(), photos.to_string_lossy().into_owned())]); + Self { + dir, + catalog, + path, + photos, + mappings, + } + } + + fn photo(&self, id: i64, name: &str, settings: &[u8]) -> PathBuf { + self.catalog.image(id, 1, name, "ARW"); + let path = touch(&self.photos.join(format!("{name}.ARW"))); + self.develop(id, settings); + path + } + + fn develop(&self, id: i64, settings: &[u8]) { + self.catalog + .connection + .execute( + "UPDATE Adobe_images SET orientation='AB', rating=3 WHERE id_local=?1", + [id], + ) + .unwrap(); + self.catalog + .connection + .execute( + "INSERT INTO Adobe_imageDevelopSettings VALUES (?1, ?2, 8, 4)", + params![id, settings], + ) + .unwrap(); + self.catalog + .connection + .execute( + "INSERT INTO Adobe_AdditionalMetadata VALUES (?1, ?2)", + params![id, ""], + ) + .unwrap(); + } + + fn prepare(&self) -> PreparedImport { + prepare(&self.path, &self.mappings).unwrap() + } + + fn apply( + &self, + fingerprint: &str, + ids: &[i64], + edits: bool, + ratings: bool, + replace_ratings: bool, + ) -> Result { + apply_import( + &self.path, + &self.mappings, + fingerprint, + ids, + edits, + ratings, + replace_ratings, + ) + } + } + + const SETTINGS: &[u8] = b"s = { Exposure2012 = 0.5, FutureScalar = 7 }"; + + #[test] + fn preview_is_read_only_and_apply_requires_selected_photos() { + let _mode = CardMode::off(); + let library = Library::new(); + let first = library.photo(1, "first", SETTINGS); + let second = library.photo(2, "second", SETTINGS); + let before = folder_state(library.dir.path()); + let preview = library.prepare(); + assert_eq!(folder_state(library.dir.path()), before); + assert_eq!(preview.photos.len(), 2); + assert_eq!( + preview.photos[0].preview.adjustments["exposure"], + json!(0.5) + ); + assert!( + preview.photos[0] + .preview + .unsupported + .contains(&"FutureScalar".into()) + ); + let result = library + .apply(&preview.fingerprint, &[1], false, true, false) + .unwrap(); + assert_eq!(result.imported, 1); + let (_, saved) = parse_virtual_path(&first.to_string_lossy()); + let metadata: ImageMetadata = serde_json::from_slice(&fs::read(saved).unwrap()).unwrap(); + assert_eq!(metadata.adjustments["exposure"], json!(0.5)); + assert_eq!(metadata.rating, 3); + assert!(metadata.rating_is_explicit); + assert!(!parse_virtual_path(&second.to_string_lossy()).1.exists()); + for path in [first, second, library.path.clone()] { + assert_eq!(fs::read(&path).unwrap(), before[&path].0); + } + } + + #[test] + fn existing_edits_and_ratings_need_independent_replacement_choices() { + let _mode = CardMode::off(); + let library = Library::new(); + let raw = library.photo(1, "existing", SETTINGS); + let (_, sidecar) = parse_virtual_path(&raw.to_string_lossy()); + let original = json!({"version":1, "rating":5, "rating_is_explicit":true, + "adjustments":{"exposure":2}, "tags":["keep"], "flag":"pick", + "flag_is_explicit":true, "futureMetadata":{"keep":true}}); + fs::write(&sidecar, serde_json::to_vec(&original).unwrap()).unwrap(); + let preview = library.prepare(); + assert!(preview.photos[0].preview.existing_edits); + let result = library + .apply(&preview.fingerprint, &[1], false, true, false) + .unwrap(); + assert_eq!(result.preserved, 1); + assert_eq!( + serde_json::from_slice::(&fs::read(&sidecar).unwrap()).unwrap(), + original + ); + let preview = library.prepare(); + library + .apply(&preview.fingerprint, &[1], true, true, false) + .unwrap(); + let saved: Value = serde_json::from_slice(&fs::read(&sidecar).unwrap()).unwrap(); + assert_eq!(saved["adjustments"]["exposure"], json!(0.5)); + assert_eq!(saved["rating"], json!(5)); + for key in ["tags", "flag", "flag_is_explicit", "futureMetadata"] { + assert_eq!(saved[key], original[key]); + } + let preview = library.prepare(); + library + .apply(&preview.fingerprint, &[1], false, true, true) + .unwrap(); + let saved: Value = serde_json::from_slice(&fs::read(&sidecar).unwrap()).unwrap(); + assert_eq!(saved["rating"], json!(3)); + assert_eq!(saved["adjustments"]["exposure"], json!(0.5)); + } + + #[test] + fn stale_previews_cannot_replace_newer_edits_or_catalog_rows() { + let _mode = CardMode::off(); + let library = Library::new(); + let raw = library.photo(1, "changing", SETTINGS); + let preview = library.prepare(); + let (_, sidecar) = parse_virtual_path(&raw.to_string_lossy()); + let saved = + serde_json::to_vec(&json!({"version":1,"rating":1,"adjustments":{"exposure":4}})) + .unwrap(); + fs::write(&sidecar, &saved).unwrap(); + assert!( + library + .apply(&preview.fingerprint, &[1], true, true, true) + .unwrap_err() + .contains("changed after preview") + ); + assert_eq!(fs::read(&sidecar).unwrap(), saved); + let preview = library.prepare(); + library.catalog.connection.execute("UPDATE Adobe_imageDevelopSettings SET text='s = { Exposure2012 = 1 }' WHERE image=1", []).unwrap(); + assert!( + library + .apply(&preview.fingerprint, &[1], true, true, true) + .is_err() + ); + assert_eq!(fs::read(&sidecar).unwrap(), saved); + } + + #[test] + fn virtual_copy_ids_are_stable_and_keep_master_edits_untouched() { + let _mode = CardMode::off(); + let library = Library::new(); + let master = library.photo(1, "master", SETTINGS); + library.catalog.virtual_copy(16_777_217, 1); + library.develop(16_777_217, SETTINGS); + library.catalog.virtual_copy(33_554_433, 1); + library.develop(33_554_433, SETTINGS); + let before = folder_state(library.dir.path()); + let preview = library.prepare(); + assert_ne!( + preview.photos[1].preview.path, + preview.photos[2].preview.path + ); + assert_eq!( + preview.photos[1].preview.path, + library.prepare().photos[1].preview.path + ); + let result = library + .apply( + &preview.fingerprint, + &[16_777_217, 33_554_433], + false, + true, + false, + ) + .unwrap(); + assert_eq!(result.virtual_copies, 2); + assert!(!parse_virtual_path(&master.to_string_lossy()).1.exists()); + assert_eq!(fs::read(&master).unwrap(), before[&master].0); + let preview = library.prepare(); + let result = library + .apply( + &preview.fingerprint, + &[16_777_217, 33_554_433], + false, + true, + false, + ) + .unwrap(); + assert_eq!(result.preserved, 2); + assert_eq!(result.virtual_copies, 0); + } + + #[test] + fn compressed_rows_are_bounded_and_malformed_rows_block_a_selected_batch() { + let _mode = CardMode::off(); + let library = Library::new(); + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(SETTINGS).unwrap(); + let mut compressed = (SETTINGS.len() as u32).to_le_bytes().to_vec(); + compressed.extend(encoder.finish().unwrap()); + library.photo(1, "compressed", &compressed); + library.photo(2, "malformed", b"s = { Exposure2012 = os.execute('bad') }"); + let before = folder_state(library.dir.path()); + let preview = library.prepare(); + assert_eq!( + preview.photos[0].preview.adjustments["exposure"], + json!(0.5) + ); + assert!(preview.photos[1].preview.error.is_some()); + assert!( + library + .apply(&preview.fingerprint, &[1, 2], false, true, false) + .is_err() + ); + assert_eq!(folder_state(library.dir.path()), before); + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&vec![b' '; MAX_TEXT_BYTES + 1]).unwrap(); + let mut too_big = vec![0; 4]; + too_big.extend(encoder.finish().unwrap()); + assert!(decode_text(&too_big).unwrap_err().contains("Expanded")); + } + + #[test] + fn card_mode_is_checked_again_when_applying_a_preview() { + let library = Library::new(); + library.photo(1, "card", SETTINGS); + let preview = library.prepare(); + let before = folder_state(library.dir.path()); + let _mode = CardMode::on(&library.photos); + assert!( + library + .apply(&preview.fingerprint, &[1], false, true, false) + .unwrap_err() + .contains("Card mode") + ); + assert_eq!(folder_state(library.dir.path()), before); + } + + #[test] + fn portrait_crops_and_history_white_balance_use_the_shared_mapper() { + let _mode = CardMode::off(); + let library = Library::new(); + library.photo(1, "portrait", b"s = { CropLeft=0.125, CropRight=0.25, CropTop=0, CropBottom=0.25, WhiteBalance='Custom', Temperature=5550, Tint=12 }"); + library + .catalog + .connection + .execute( + "UPDATE Adobe_images SET orientation='BC' WHERE id_local=1", + [], + ) + .unwrap(); + library + .catalog + .connection + .execute( + "INSERT INTO Adobe_libraryImageDevelopHistoryStep VALUES (1, ?1, 0)", + ["s = { WhiteBalance='As Shot', Temperature=4440, Tint=-5 }"], + ) + .unwrap(); + let preview = library.prepare(); + let adjustments = &preview.photos[0].preview.adjustments; + assert_eq!(adjustments["orientationSteps"], json!(1)); + assert_eq!( + adjustments["crop"], + json!({"x":3.0,"y":1.0,"width":1.0,"height":1.0}) + ); + let expected = (1_000_000.0 / 4440.0 - 1_000_000.0 / 5550.0) / 150.0 * 100.0; + assert!((adjustments["temperature"].as_f64().unwrap() - expected).abs() < 1e-6); + assert!( + !preview.photos[0] + .preview + .unsupported + .contains(&"whiteBalance".into()) + ); + } + + #[test] + fn unreadable_existing_edits_and_missing_photos_are_never_overwritten() { + let _mode = CardMode::off(); + let library = Library::new(); + let raw = library.photo(1, "broken-sidecar", SETTINGS); + let (_, sidecar) = parse_virtual_path(&raw.to_string_lossy()); + fs::write(&sidecar, b"{ broken json").unwrap(); + let missing = library.photo(2, "missing", SETTINGS); + fs::remove_file(&missing).unwrap(); + let before = folder_state(library.dir.path()); + let preview = library.prepare(); + assert!(preview.photos[0].preview.error.is_some()); + assert!(!preview.photos[1].preview.found); + assert!( + library + .apply(&preview.fingerprint, &[1], true, true, true) + .is_err() + ); + assert!( + library + .apply(&preview.fingerprint, &[2], true, true, true) + .is_err() + ); + assert_eq!(folder_state(library.dir.path()), before); + } + + #[cfg(unix)] + #[test] + fn sidecar_write_failures_are_reported_without_damaging_originals() { + use std::os::unix::fs::PermissionsExt; + let _mode = CardMode::off(); + let library = Library::new(); + let raw = library.photo(1, "unwritable", SETTINGS); + let preview = library.prepare(); + let original = fs::read(&raw).unwrap(); + let permissions = fs::metadata(&library.photos).unwrap().permissions(); + fs::set_permissions(&library.photos, fs::Permissions::from_mode(0o555)).unwrap(); + let result = library.apply(&preview.fingerprint, &[1], false, true, false); + fs::set_permissions(&library.photos, permissions).unwrap(); + let result = result.unwrap(); + assert_eq!(result.imported, 0); + assert_eq!(result.errors.len(), 1); + assert!(!parse_virtual_path(&raw.to_string_lossy()).1.exists()); + assert_eq!(fs::read(&raw).unwrap(), original); + } +} diff --git a/src-tauri/src/lightroom/mod.rs b/src-tauri/src/lightroom/mod.rs index ae06f4ead..90b0e902b 100644 --- a/src-tauri/src/lightroom/mod.rs +++ b/src-tauri/src/lightroom/mod.rs @@ -1,4 +1,5 @@ pub mod catalog; pub mod collections; +pub mod develop; #[cfg(test)] mod test_catalog; diff --git a/src-tauri/src/lrtemplate.rs b/src-tauri/src/lrtemplate.rs index d14f26d59..14ed57c19 100644 --- a/src-tauri/src/lrtemplate.rs +++ b/src-tauri/src/lrtemplate.rs @@ -424,6 +424,68 @@ pub fn lrtemplate_to_xmp(source: &str) -> Result { return Err("Malformed .lrtemplate: missing `value.settings` table".to_string()); }; + settings_to_xmp(settings, root.get("title"), false) +} + +/// Catalog develop rows contain a settings table directly, rather than the +/// preset wrapper. They use the same bounded parser and XMP encoder. +pub fn catalog_develop_to_xmp( + source: &str, + as_shot: Option<(f64, f64)>, +) -> Result { + if source.len() > MAX_INPUT_BYTES { + return Err("Catalog develop row is too large".into()); + } + let mut settings = + Parser::new(source.strip_prefix('\u{feff}').unwrap_or(source)).parse_chunk()?; + if let Some((temperature, tint)) = as_shot { + if !temperature.is_finite() || temperature <= 0.0 || !tint.is_finite() { + return Err("Invalid catalog as-shot white balance".into()); + } + settings + .fields + .retain(|(key, _)| key != "AsShotTemperature" && key != "AsShotTint"); + settings + .fields + .push(("AsShotTemperature".into(), LuaValue::Num(temperature))); + settings + .fields + .push(("AsShotTint".into(), LuaValue::Num(tint))); + } + if settings.get("HasCrop").is_none() + && ["CropLeft", "CropRight", "CropTop", "CropBottom"] + .iter() + .all(|key| matches!(settings.get(key), Some(LuaValue::Num(_)))) + { + settings + .fields + .push(("HasCrop".into(), LuaValue::Bool(true))); + } + settings_to_xmp(&settings, None, true) +} + +/// Only an explicitly as-shot history row supplies a white-balance baseline. +/// No estimates are made from other photos in the folder. +pub fn catalog_as_shot_white_balance(source: &str) -> Result, String> { + if source.len() > MAX_INPUT_BYTES { + return Err("Catalog develop row is too large".into()); + } + let settings = Parser::new(source.strip_prefix('\u{feff}').unwrap_or(source)).parse_chunk()?; + if matches!(settings.get("WhiteBalance"), Some(LuaValue::Str(value)) if value.eq_ignore_ascii_case("As Shot")) + && let (Some(LuaValue::Num(temperature)), Some(LuaValue::Num(tint))) = + (settings.get("Temperature"), settings.get("Tint")) + && *temperature > 0.0 + { + return Ok(Some((*temperature, *tint))); + } + Ok(None) +} + +fn settings_to_xmp( + settings: &LuaTable, + title: Option<&LuaValue>, + report_scalars: bool, +) -> Result { let mut attributes = String::new(); let mut curves = String::new(); let mut unsupported = Vec::new(); @@ -471,6 +533,28 @@ pub fn lrtemplate_to_xmp(source: &str) -> Result { matches!(table.get("Amount"), Some(LuaValue::Num(n)) if *n <= 0.0); if !disabled { report("profileLook"); + // Reuse the mapper's profile-look luma composition while + // reporting unsupported profile color and LUT settings. + if report_scalars + && let Some(LuaValue::Table(parameters)) = table.get("Parameters") + && let Some(LuaValue::Table(curve)) = parameters.get("ToneCurvePV2012") + { + let points = curve_points("ToneCurvePV2012", curve)?; + if !points.is_empty() { + let amount = match table.get("Amount") { + Some(LuaValue::Num(value)) => *value, + _ => 1.0, + }; + curves.push_str(&format!( + "", + format_number(amount) + )); + for (x, y) in points { + curves.push_str(&format!("{x}, {y}")); + } + curves.push_str(""); + } + } } } else if MASK_KEYS.contains(&key.as_str()) { report("masks"); @@ -482,6 +566,9 @@ pub fn lrtemplate_to_xmp(source: &str) -> Result { continue; } }; + if report_scalars && !crate::preset_converter::is_mapped_xmp_scalar(key) { + report(key); + } attributes.push_str(&format!(" crs:{}=\"{}\"", key, escape_xml(&scalar))); } @@ -489,7 +576,7 @@ pub fn lrtemplate_to_xmp(source: &str) -> Result { "", attributes ); - if let Some(LuaValue::Str(title)) = root.get("title") { + if let Some(LuaValue::Str(title)) = title { let title = localized_title(title).trim(); if !title.is_empty() { xmp.push_str(&format!( @@ -510,6 +597,104 @@ mod tests { use crate::preset_converter::convert_xmp_to_preset; use serde_json::json; + #[test] + fn catalog_rows_use_the_shared_mapper_and_report_nested_settings() { + let converted = catalog_develop_to_xmp( + r#"s = { Exposure2012 = 0.5, Sharpness = 40, ColorNoiseReduction = 25, + ToneCurvePV2012 = { 0, 0, 255, 255 }, + Look = { Parameters = { Exposure2012 = 9 } }, + GradientBasedCorrections = { { LocalExposure = 1 } }, FutureScalar = 7 }"#, + None, + ) + .unwrap(); + let preset = convert_xmp_to_preset(&converted.xmp).unwrap(); + assert_eq!(preset.adjustments["exposure"], json!(0.5)); + let reference = convert_xmp_to_preset( + r#""#, + ) + .unwrap(); + assert_eq!( + preset.adjustments["sharpness"], + reference.adjustments["sharpness"] + ); + assert_eq!( + preset.adjustments["colorNoiseReduction"], + reference.adjustments["colorNoiseReduction"] + ); + assert_eq!( + preset.adjustments["curves"]["luma"], + json!([{ "x": 0, "y": 0 }, { "x": 255, "y": 255 }]) + ); + assert_eq!( + converted.unsupported, + ["profileLook", "masks", "FutureScalar"] + ); + assert!(catalog_develop_to_xmp("s = { Exposure2012 = os.execute('cmd') }", None).is_err()); + } + + #[test] + fn catalog_nested_look_curves_reuse_the_existing_composition_mapper() { + let converted = catalog_develop_to_xmp( + "s = { Exposure2012 = 0.5, ToneCurvePV2012 = {0, 0, 255, 255}, Look = { Amount = 0.5, Parameters = { Exposure2012 = 9, ToneCurvePV2012 = {0, 0, 64, 48, 192, 208, 255, 255} } } }", + None, + ).unwrap(); + let preset = convert_xmp_to_preset(&converted.xmp).unwrap(); + let expected = convert_xmp_to_preset( + r#"0, 0255, 2550, 064, 48192, 208255, 255"#, + ).unwrap(); + assert_eq!(preset.adjustments, expected.adjustments); + assert_eq!(preset.adjustments["exposure"], json!(0.5)); + assert_eq!(converted.unsupported, ["profileLook"]); + } + + #[test] + fn catalog_white_balance_uses_an_explicit_as_shot_history_row() { + let baseline = catalog_as_shot_white_balance( + "s = { WhiteBalance = 'As Shot', Temperature = 4440, Tint = -5 }", + ) + .unwrap(); + assert_eq!(baseline, Some((4440.0, -5.0))); + assert_eq!( + catalog_as_shot_white_balance( + "s = { WhiteBalance = 'Custom', Temperature = 5550, Tint = 12 }" + ) + .unwrap(), + None + ); + let converted = catalog_develop_to_xmp( + "s = { WhiteBalance = 'Custom', Temperature = 5550, Tint = 12, AsShotTemperature = 1, AsShotTint = 1 }", + baseline, + ) + .unwrap(); + assert_eq!(converted.xmp.matches("crs:AsShotTemperature=").count(), 1); + let preset = + crate::preset_converter::convert_xmp_sidecar_to_preset(&converted.xmp).unwrap(); + let expected_temperature = (1_000_000.0 / 4440.0 - 1_000_000.0 / 5550.0) / 150.0 * 100.0; + assert!( + (preset.adjustments["temperature"].as_f64().unwrap() - expected_temperature).abs() + < 1e-6 + ); + assert!((preset.adjustments["tint"].as_f64().unwrap() - 17.0 / 150.0 * 100.0).abs() < 1e-6); + } + + #[test] + fn catalog_crops_go_through_the_existing_sidecar_geometry_mapper() { + let converted = catalog_develop_to_xmp( + "s = { CropLeft = 0.125, CropRight = 0.25, CropTop = 0, CropBottom = 0.25 }", + None, + ) + .unwrap(); + let xmp = converted.xmp.replace( + ", key: &str) -> Option { .and_then(|s| s.trim_start_matches('+').parse::().ok()) } -fn extract_namespaced_scalar(content: &str, prefix: &str, key: &str) -> Option { +pub(crate) fn extract_namespaced_scalar(content: &str, prefix: &str, key: &str) -> Option { let prefix = regex::escape(prefix); let key = regex::escape(key); let attr_pattern = format!(r#"{}:{}="([^"]*)""#, prefix, key); @@ -1267,6 +1267,107 @@ fn parse_xmp_attributes(xmp_content: &str) -> Result, St Ok(attrs) } +const BASIC_MAPPINGS: &[(&str, &str)] = &[ + ("Exposure2012", "exposure"), + ("Contrast2012", "contrast"), + ("Highlights2012", "highlights"), + ("Shadows2012", "shadows"), + ("Whites2012", "whites"), + ("Blacks2012", "blacks"), + ("Clarity2012", "clarity"), + ("Dehaze", "dehaze"), + ("Vibrance", "vibrance"), + ("Saturation", "saturation"), + ("Texture", "structure"), + ("LuminanceSmoothing", "lumaNoiseReduction"), + ("ColorNoiseReduction", "colorNoiseReduction"), + ("ChromaticAberrationRedCyan", "chromaticAberrationRedCyan"), + ( + "ChromaticAberrationBlueYellow", + "chromaticAberrationBlueYellow", + ), + ("PostCropVignetteAmount", "vignetteAmount"), + ("PostCropVignetteMidpoint", "vignetteMidpoint"), + ("PostCropVignetteFeather", "vignetteFeather"), + ("PostCropVignetteRoundness", "vignetteRoundness"), + ("GrainAmount", "grainAmount"), + ("GrainSize", "grainSize"), + ("GrainFrequency", "grainRoughness"), + ("ColorGradeBlending", "blending"), +]; + +pub(crate) fn is_mapped_xmp_scalar(key: &str) -> bool { + if BASIC_MAPPINGS.iter().any(|(name, _)| *name == key) { + return true; + } + for prefix in [ + "HueAdjustment", + "SaturationAdjustment", + "LuminanceAdjustment", + "GrayMixer", + ] { + if let Some(color) = key.strip_prefix(prefix) + && [ + "Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta", + ] + .contains(&color) + { + return true; + } + } + matches!( + key, + "Sharpness" + | "SharpenEdgeMasking" + | "Exposure" + | "Brightness" + | "Contrast" + | "Recovery" + | "FillLight" + | "Shadows" + | "Clarity" + | "Temperature" + | "Tint" + | "AsShotTemperature" + | "AsShotTint" + | "WhiteBalance" + | "IncrementalTemperature" + | "IncrementalTint" + | "ConvertToGrayscale" + | "HasCrop" + | "CropLeft" + | "CropRight" + | "CropTop" + | "CropBottom" + | "CropAngle" + | "CropConstrainAspectRatio" + | "LensProfileEnable" + | "LensProfileDistortionScale" + | "LensProfileVignettingScale" + | "AutoLateralCA" + | "SplitToningShadowHue" + | "SplitToningShadowSaturation" + | "SplitToningHighlightHue" + | "SplitToningHighlightSaturation" + | "SplitToningBalance" + | "ColorGradeMidtoneHue" + | "ColorGradeMidtoneSat" + | "ColorGradeShadowLum" + | "ColorGradeMidtoneLum" + | "ColorGradeHighlightLum" + | "ColorGradeGlobalHue" + | "ColorGradeGlobalSat" + | "ColorGradeGlobalLum" + | "Version" + | "ProcessVersion" + | "HasSettings" + | "AlreadyApplied" + | "CameraProfile" + | "ToneCurveName" + | "ToneCurveName2012" + ) +} + fn convert_xmp_to_preset_with_crop( xmp_content: &str, include_crop_transform: bool, @@ -1282,36 +1383,7 @@ fn convert_xmp_to_preset_with_crop( let mut color_grading_map = Map::new(); let mut curves_map = Map::new(); - let mappings = vec![ - ("Exposure2012", "exposure"), - ("Contrast2012", "contrast"), - ("Highlights2012", "highlights"), - ("Shadows2012", "shadows"), - ("Whites2012", "whites"), - ("Blacks2012", "blacks"), - ("Clarity2012", "clarity"), - ("Dehaze", "dehaze"), - ("Vibrance", "vibrance"), - ("Saturation", "saturation"), - ("Texture", "structure"), - ("LuminanceSmoothing", "lumaNoiseReduction"), - ("ColorNoiseReduction", "colorNoiseReduction"), - ("ChromaticAberrationRedCyan", "chromaticAberrationRedCyan"), - ( - "ChromaticAberrationBlueYellow", - "chromaticAberrationBlueYellow", - ), - ("PostCropVignetteAmount", "vignetteAmount"), - ("PostCropVignetteMidpoint", "vignetteMidpoint"), - ("PostCropVignetteFeather", "vignetteFeather"), - ("PostCropVignetteRoundness", "vignetteRoundness"), - ("GrainAmount", "grainAmount"), - ("GrainSize", "grainSize"), - ("GrainFrequency", "grainRoughness"), - ("ColorGradeBlending", "blending"), - ]; - - for (xmp_key, rr_key) in mappings { + for &(xmp_key, rr_key) in BASIC_MAPPINGS { if let Some(raw_val) = attrs.get(xmp_key) && let Some(num) = parse_num(raw_val.trim_start_matches('+')) && let Some(json_val) = num_to_json(num) diff --git a/src/components/modals/AppModals.tsx b/src/components/modals/AppModals.tsx index 705c5828a..88275ae49 100644 --- a/src/components/modals/AppModals.tsx +++ b/src/components/modals/AppModals.tsx @@ -321,6 +321,7 @@ export default function AppModals(props: AppModalsProps) { /> setUI({ lightroomImportCatalog: null })} refreshAllFolderTrees={props.refreshAllFolderTrees} /> diff --git a/src/components/modals/LightroomDevelopImport.test.tsx b/src/components/modals/LightroomDevelopImport.test.tsx new file mode 100644 index 000000000..ae6bd6197 --- /dev/null +++ b/src/components/modals/LightroomDevelopImport.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { invoke, mockCommand } from '../../test/tauriMock'; +import { Invokes } from '../ui/AppProperties'; +import LightroomDevelopImport, { type DevelopPhoto, type DevelopPreview } from './LightroomDevelopImport'; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +let root: Root | undefined; +let container: HTMLDivElement; +afterEach(async () => { + await act(async () => root?.unmount()); + root = undefined; + container?.remove(); +}); +const photo = (id: number, extra: Partial = {}): DevelopPhoto => ({ + id, + path: `/photos/${id}.ARW`, + copyName: '', + virtualCopy: false, + found: true, + existingSidecar: false, + existingEdits: false, + rating: 3, + adjustments: { exposure: 0.5 }, + unsupported: ['profileLook'], + error: null, + ...extra, +}); +const preview: DevelopPreview = { + catalogName: 'Catalog', + fingerprint: 'snapshot-a', + photos: [ + photo(1), + photo(2, { existingSidecar: true, existingEdits: true }), + photo(3, { found: false }), + photo(4, { error: 'Unreadable saved edits' }), + ], +}; +const args = { path: '/Catalog.lrcat', mappings: {} }; +function calls(cmd: string) { + return invoke.mock.calls.filter(([name]) => name === cmd).map(([, options]) => options); +} +function button(key: string) { + const item = [...container.querySelectorAll('button')].find( + (node) => node.textContent === `modals.lightroomDevelop.${key}`, + ); + if (!item) throw new Error(`Missing ${key}`); + return item; +} +function option(key: string) { + const item = [...container.querySelectorAll('label')] + .find((node) => node.textContent === `modals.lightroomDevelop.${key}`) + ?.querySelector('input'); + if (!item) throw new Error(`Missing ${key}`); + return item; +} +async function mount(isCardMode = false) { + mockCommand(Invokes.PreviewLightroomDevelop, () => preview); + mockCommand(Invokes.ImportLightroomDevelop, () => ({ imported: 1, preserved: 0, virtualCopies: 0, errors: [] })); + const onImported = vi.fn(async () => {}), + onClose = vi.fn(), + onBusyChange = vi.fn(); + const props = { catalogPath: args.path, mappings: args.mappings, isCardMode, onImported, onClose, onBusyChange }; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => root?.render()); + return props; +} + +describe('Lightroom catalog photo edits', () => { + it('previews all rows, selects only new readable photos, and imports only after an explicit click', async () => { + const props = await mount(); + expect(calls(Invokes.ImportLightroomDevelop)).toEqual([]); + expect(container.textContent).toContain('exposure'); + expect(container.textContent).toContain('modals.lightroomDevelop.unsupported'); + expect(container.textContent).toContain('Unreadable saved edits'); + expect(container.querySelectorAll('li input:checked')).toHaveLength(1); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)).toEqual([ + { + ...args, + fingerprint: 'snapshot-a', + selections: [1], + replaceEdits: false, + importRatings: true, + replaceRatings: false, + }, + ]); + expect(props.onImported).toHaveBeenCalledOnce(); + expect(props.onClose).toHaveBeenCalledOnce(); + expect(props.onBusyChange.mock.calls).toEqual([[true], [false]]); + }); + it('keeps edit and rating replacement independent, and excludes missing or malformed rows from select-all', async () => { + await mount(); + await act(async () => button('selectAvailable').click()); + await act(async () => option('replaceRatings').click()); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)[0]).toMatchObject({ + selections: [1, 2], + replaceEdits: false, + importRatings: true, + replaceRatings: true, + }); + }); + it('disables replacement ratings when rating import is off', async () => { + await mount(); + await act(async () => option('replaceRatings').click()); + await act(async () => option('importRatings').click()); + expect(option('replaceRatings').disabled).toBe(true); + await act(async () => option('replaceEdits').click()); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)[0]).toMatchObject({ + replaceEdits: true, + importRatings: false, + replaceRatings: false, + }); + }); + it('refreshes after a partial write failure and leaves the report open', async () => { + const props = await mount(); + mockCommand(Invokes.ImportLightroomDevelop, () => ({ + imported: 1, + preserved: 0, + virtualCopies: 1, + errors: ['/photos/2.ARW: Permission denied'], + })); + mockCommand(Invokes.PreviewLightroomDevelop, () => ({ + ...preview, + fingerprint: 'snapshot-b', + photos: [photo(1, { existingSidecar: true }), photo(2)], + })); + await act(async () => button('apply').click()); + expect(props.onImported).toHaveBeenCalledOnce(); + expect(props.onClose).not.toHaveBeenCalled(); + expect(container.textContent).toContain('Permission denied'); + expect(calls(Invokes.PreviewLightroomDevelop)).toHaveLength(2); + expect(container.querySelectorAll('li input:checked')).toHaveLength(1); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)[1]).toMatchObject({ fingerprint: 'snapshot-b', selections: [2] }); + }); + it('shows stale-preview errors and fetches a fresh fingerprint before retrying', async () => { + const props = await mount(); + mockCommand(Invokes.ImportLightroomDevelop, () => { + throw new Error('Preview changed; refresh.'); + }); + mockCommand(Invokes.PreviewLightroomDevelop, () => ({ ...preview, fingerprint: 'snapshot-c' })); + await act(async () => button('apply').click()); + expect(container.textContent).toContain('Preview changed; refresh.'); + expect(props.onClose).not.toHaveBeenCalled(); + expect(props.onImported).not.toHaveBeenCalled(); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)[1]).toMatchObject({ fingerprint: 'snapshot-c' }); + }); + it('prevents writes in Card mode', async () => { + await mount(true); + expect(button('apply').disabled).toBe(true); + expect(option('replaceEdits').disabled).toBe(true); + expect(container.textContent).toContain('modals.lightroomDevelop.cardMode'); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)).toEqual([]); + }); + it('uses relinked paths in the refreshed preview and apply request', async () => { + const props = await mount(); + const mappings = { 'C:/Pictures': '/photos' }; + await act(async () => root?.render()); + expect(calls(Invokes.PreviewLightroomDevelop)[1]).toEqual({ path: args.path, mappings }); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)[0]).toMatchObject({ mappings }); + }); +}); diff --git a/src/components/modals/LightroomDevelopImport.tsx b/src/components/modals/LightroomDevelopImport.tsx new file mode 100644 index 000000000..40c7adebf --- /dev/null +++ b/src/components/modals/LightroomDevelopImport.tsx @@ -0,0 +1,299 @@ +import { useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { Invokes } from '../ui/AppProperties'; +import Switch from '../ui/Switch'; +import Text from '../ui/Text'; + +export interface DevelopPhoto { + id: number; + path: string; + copyName: string; + virtualCopy: boolean; + found: boolean; + existingSidecar: boolean; + existingEdits: boolean; + rating: number | null; + adjustments: Record; + unsupported: string[]; + error: string | null; +} +export interface DevelopPreview { + catalogName: string; + fingerprint: string; + photos: DevelopPhoto[]; +} +interface ImportResult { + imported: number; + preserved: number; + virtualCopies: number; + errors: string[]; +} +interface Props { + catalogPath: string; + mappings: Record; + isCardMode: boolean; + onBusyChange(busy: boolean): void; + onImported(): Promise; + onClose(): void; +} +const PAGE_SIZE = 50; +const canImport = (photo: DevelopPhoto) => photo.found && !photo.error; +const readable = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' '); + +// Keep the preview readable without exposing a JSON document as an import flow. +function adjustmentDescription(value: unknown): string { + if (typeof value === 'number') return Number(value.toFixed(3)).toLocaleString(); + if (typeof value === 'string' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return value.map(adjustmentDescription).join('; '); + if (value && typeof value === 'object') { + return Object.entries(value) + .map(([key, entry]) => `${readable(key)}: ${adjustmentDescription(entry)}`) + .join(', '); + } + return '—'; +} + +export default function LightroomDevelopImport({ + catalogPath, + mappings, + isCardMode, + onBusyChange, + onImported, + onClose, +}: Props) { + const { t } = useTranslation(); + const [preview, setPreview] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [page, setPage] = useState(0); + const [refresh, setRefresh] = useState(0); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [applyError, setApplyError] = useState(null); + const [result, setResult] = useState(null); + const [replaceEdits, setReplaceEdits] = useState(false); + const [importRatings, setImportRatings] = useState(true); + const [replaceRatings, setReplaceRatings] = useState(false); + + useEffect(() => { + setResult(null); + setApplyError(null); + }, [catalogPath, mappings]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setPreview(null); + setSelected(new Set()); + setPage(0); + invoke(Invokes.PreviewLightroomDevelop, { path: catalogPath, mappings }) + .then((next) => { + if (cancelled) return; + setPreview(next); + setSelected( + new Set(next.photos.filter((photo) => canImport(photo) && !photo.existingSidecar).map((photo) => photo.id)), + ); + setError(null); + }) + .catch((err) => { + if (!cancelled) setError(String(err)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [catalogPath, mappings, refresh]); + + const handleImport = async () => { + if (!preview || busy || loading || isCardMode || selected.size === 0) return; + setBusy(true); + onBusyChange(true); + setApplyError(null); + try { + const next = await invoke(Invokes.ImportLightroomDevelop, { + path: catalogPath, + mappings, + fingerprint: preview.fingerprint, + selections: [...selected], + replaceEdits, + importRatings, + replaceRatings: importRatings && replaceRatings, + }); + setResult(next); + await onImported(); + if (next.errors.length === 0) { + toast.success(t('modals.lightroomDevelop.result', { ...next })); + onClose(); + } + } catch (err) { + setApplyError(String(err)); + } finally { + setRefresh((value) => value + 1); + setBusy(false); + onBusyChange(false); + } + }; + const disabled = busy || loading || isCardMode; + const pageCount = Math.max(1, Math.ceil((preview?.photos.length ?? 0) / PAGE_SIZE)); + const unsupportedLabel = (key: string) => t(`modals.lightroomDevelop.fields.${key}`, { defaultValue: readable(key) }); + + return ( +
+ {t('modals.lightroomDevelop.hint')} + {t('modals.lightroomDevelop.preserved')} + {isCardMode && {t('modals.lightroomDevelop.cardMode')}} + {error && {error}} + {applyError && ( + + {applyError} + + )} + {result && ( +
+ {t('modals.lightroomDevelop.result', { ...result })} + {result.errors.length > 0 && ( +
    + {result.errors.map((item, index) => ( +
  • {item}
  • + ))} +
+ )} +
+ )} + + {loading && {t('modals.lightroomDevelop.reading')}} + {preview && ( + <> +
+ + + +
+
+ + + + {t('modals.lightroomDevelop.selected', { selected: selected.size, total: preview.photos.length })} + +
+ {preview.photos.length === 0 && {t('modals.lightroomDevelop.empty')}} +
    + {preview.photos.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE).map((photo) => ( +
  • + + + {photo.existingSidecar + ? t('modals.lightroomDevelop.existing') + : t('modals.lightroomDevelop.newPhoto')} + {photo.virtualCopy && ` · ${t('modals.lightroomDevelop.virtualCopy')}`} + {' · '} + {photo.rating === null + ? t('modals.lightroomDevelop.noRating') + : t('modals.lightroomDevelop.rating', { rating: photo.rating })} + + {(!photo.found || photo.error) && ( + {photo.error ?? t('modals.lightroomDevelop.missing')} + )} +
    + {t('modals.lightroomDevelop.details')} + {Object.keys(photo.adjustments).length === 0 ? ( + {t('modals.lightroomDevelop.noAdjustments')} + ) : ( +
    + {Object.entries(photo.adjustments).map(([key, value]) => ( +
    +
    {readable(key)}
    +
    {adjustmentDescription(value)}
    +
    + ))} +
    + )} +
    + {photo.unsupported.length > 0 && ( + + {t('modals.lightroomDevelop.unsupported', { + items: photo.unsupported.map(unsupportedLabel).join(', '), + })} + + )} +
  • + ))} +
+ {pageCount > 1 && ( +
+ + {t('modals.lightroomDevelop.page', { page: page + 1, total: pageCount })} + +
+ )} + + + )} +
+ ); +} diff --git a/src/components/modals/LightroomImportModal.test.tsx b/src/components/modals/LightroomImportModal.test.tsx index fe3f62c96..1f623a907 100644 --- a/src/components/modals/LightroomImportModal.test.tsx +++ b/src/components/modals/LightroomImportModal.test.tsx @@ -82,7 +82,12 @@ async function mount() { root = createRoot(container); await act(async () => root?.render( - , + {}} + />, ), ); return { handleSettingsChange, onClose, refreshAllFolderTrees, importedTree }; @@ -140,6 +145,31 @@ describe('LightroomImportModal', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('shares relinked roots with the photo-edit preview without writing during preview', async () => { + const { handleSettingsChange } = await mount(); + mockCommand(Invokes.PreviewLightroomDevelop, () => ({ + catalogName: 'Catalog', + fingerprint: 'preview', + photos: [], + })); + const tab = Array.from(document.body.querySelectorAll('button')).find( + (item) => item.textContent === 'modals.lightroomDevelop.title', + )!; + await act(async () => tab.click()); + expect(calls(Invokes.PreviewLightroomDevelop)[0]).toEqual({ + path: CATALOG, + mappings: { '/Volumes/Old/': '/mnt/old' }, + }); + pickFolder.mockResolvedValue('/mnt/pictures'); + await act(async () => button('relink').click()); + expect(calls(Invokes.PreviewLightroomDevelop)[1]).toEqual({ + path: CATALOG, + mappings: { '/Volumes/Old/': '/mnt/old', 'C:/Users/Benny/Pictures/': '/mnt/pictures' }, + }); + expect(calls(Invokes.ImportLightroomDevelop)).toEqual([]); + expect(handleSettingsChange).not.toHaveBeenCalled(); + }); + it('cancels without importing or saving anything', async () => { const { handleSettingsChange, onClose } = await mount(); diff --git a/src/components/modals/LightroomImportModal.tsx b/src/components/modals/LightroomImportModal.tsx index dcffc3a3d..946de9635 100644 --- a/src/components/modals/LightroomImportModal.tsx +++ b/src/components/modals/LightroomImportModal.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { CheckCircle2, CircleAlert, FolderSearch } from 'lucide-react'; import Switch from '../ui/Switch'; +import LightroomDevelopImport from './LightroomDevelopImport'; import Text from '../ui/Text'; import { TextVariants } from '../../types/typography'; import { AlbumItem, Invokes } from '../ui/AppProperties'; @@ -38,12 +39,14 @@ interface LightroomImportModalProps { catalogPath: string | null; onClose(): void; refreshAllFolderTrees(): Promise; + refreshImageList(): Promise; } export default function LightroomImportModal({ catalogPath, onClose, refreshAllFolderTrees, + refreshImageList, }: LightroomImportModalProps) { const { t } = useTranslation(); const [isMounted, setIsMounted] = useState(false); @@ -54,12 +57,14 @@ export default function LightroomImportModal({ const [isLoading, setIsLoading] = useState(false); const [isImporting, setIsImporting] = useState(false); const [addRootFolders, setAddRootFolders] = useState(false); + const [tab, setTab] = useState<'collections' | 'develop'>('collections'); const rootPaths = useLibraryStore((state) => state.rootPaths); const isCardMode = useLibraryStore((state) => state.cardBrowseRoot !== null); useEffect(() => { if (catalogPath) { setIsMounted(true); + setTab('collections'); setPreview(null); setError(null); setAddRootFolders(false); @@ -113,6 +118,25 @@ export default function LightroomImportModal({ .filter((root) => root.found && !rootPaths.includes(root.localPath)) .map((root) => root.localPath) ?? []; + const persistImportPaths = async () => { + if (!mappings) return; + const newRoots = addRootFolders && !isCardMode ? rootsToAdd : []; + const { rootPaths: currentRoots, expandedFolders, setLibrary } = useLibraryStore.getState(); + const updatedRoots = [...currentRoots, ...newRoots.filter((root) => !currentRoots.includes(root))]; + const { appSettings, handleSettingsChange } = useSettingsStore.getState(); + if (appSettings) { + await handleSettingsChange({ + ...appSettings, + lightroomPathMappings: { ...appSettings.lightroomPathMappings, ...mappings }, + ...(newRoots.length > 0 ? { rootFolders: updatedRoots } : {}), + }); + } + if (newRoots.length > 0) { + setLibrary({ rootPaths: updatedRoots, expandedFolders: new Set([...expandedFolders, ...newRoots]) }); + await refreshAllFolderTrees(); + } + }; + const handleImport = async () => { if (!catalogPath || !mappings || !preview) return; setIsImporting(true); @@ -124,21 +148,7 @@ export default function LightroomImportModal({ const albumTree = await invoke(Invokes.GetAlbums); useLibraryStore.getState().setLibrary({ albumTree }); - const newRoots = addRootFolders && !isCardMode ? rootsToAdd : []; - const { rootPaths: currentRoots, expandedFolders, setLibrary } = useLibraryStore.getState(); - const updatedRoots = [...currentRoots, ...newRoots.filter((root) => !currentRoots.includes(root))]; - const { appSettings, handleSettingsChange } = useSettingsStore.getState(); - if (appSettings) { - await handleSettingsChange({ - ...appSettings, - lightroomPathMappings: { ...appSettings.lightroomPathMappings, ...mappings }, - ...(newRoots.length > 0 ? { rootFolders: updatedRoots } : {}), - }); - } - if (newRoots.length > 0) { - setLibrary({ rootPaths: updatedRoots, expandedFolders: new Set([...expandedFolders, ...newRoots]) }); - await refreshAllFolderTrees(); - } + await persistImportPaths(); toast.success(t('contextMenus.toasts.importedLightroomCollections', { name: result.catalogName })); onClose(); } catch (err) { @@ -210,22 +220,43 @@ export default function LightroomImportModal({ {catalogPath} +
+ + +
{error && {error}} {!preview && !error && {t('modals.lightroomImport.reading')}} {preview && ( <> -
- {stats.map((stat) => ( -
- {stat.value.toLocaleString()} - {stat.label} -
- ))} -
- - {preview.replacesPreviousImport && ( + {tab === 'collections' && ( +
+ {stats.map((stat) => ( +
+ {stat.value.toLocaleString()} + {stat.label} +
+ ))} +
+ )} + {tab === 'collections' && preview.replacesPreviousImport && ( {t('modals.lightroomImport.replacesPrevious')} )} @@ -282,7 +313,7 @@ export default function LightroomImportModal({
)} - {preview.smartCollections.length > 0 && ( + {tab === 'collections' && preview.smartCollections.length > 0 && (
{t('modals.lightroomImport.smartCollections', { total: preview.smartCollections.length })} @@ -295,13 +326,13 @@ export default function LightroomImportModal({
)} - {preview.skippedOtherCount > 0 && ( + {tab === 'collections' && preview.skippedOtherCount > 0 && ( {t('modals.lightroomImport.skippedOther', { total: preview.skippedOtherCount })} )} - {preview.missingImageCount > 0 && ( + {tab === 'collections' && preview.missingImageCount > 0 && (
{t('modals.lightroomImport.missingFiles', { total: preview.missingImageCount })} @@ -332,10 +363,13 @@ export default function LightroomImportModal({
)} - {!hasContent && {t('modals.lightroomImport.nothingToImport')}} + {tab === 'collections' && !hasContent && ( + {t('modals.lightroomImport.nothingToImport')} + )} {rootsToAdd.length > 0 && !isCardMode && ( )} + {tab === 'develop' && catalogPath && mappings && ( + { + await persistImportPaths(); + await refreshImageList(); + }} + /> + )}
@@ -353,13 +400,15 @@ export default function LightroomImportModal({ > {t('modals.confirm.cancel')} - + {tab === 'collections' && ( + + )}
diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 3158253c4..944a7f21c 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -125,6 +125,8 @@ export enum Invokes { SaveAlbums = 'save_albums', AddToAlbum = 'add_to_album', GetAlbumImages = 'get_album_images', + PreviewLightroomDevelop = 'preview_lightroom_develop', + ImportLightroomDevelop = 'import_lightroom_develop', PreviewLightroomCollections = 'preview_lightroom_collections', ImportLightroomCollections = 'import_lightroom_collections', TetherListCameras = 'tether_list_cameras', diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f29c40ccc..6ef7b700c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -159,7 +159,7 @@ "confirmDeleteGroupNested": "Confirm Delete Group & All Nested Albums", "deleteAlbum": "Delete Album", "deleteGroup": "Delete Group", - "importLightroom": "Import Lightroom Collections…", + "importLightroom": "Import Lightroom Catalog…", "lightroomCatalog": "Lightroom Catalog", "moveHere": "Move Here", "moveTo": "Move To...", @@ -1530,7 +1530,7 @@ "rootImages": "Images: {{images}}, missing: {{missing}}", "skippedOther": "Books, slideshows, prints and web galleries, not imported: {{total}}", "smartCollections": "Smart collections, not imported: {{total}}", - "title": "Import Lightroom Collections" + "title": "Import Lightroom Catalog" }, "negativeConversion": { "blueWeight": "Blue (Yellow)", @@ -1641,6 +1641,46 @@ "yAxis": "Y Axis", "zoomInTooltip": "Zoom In", "zoomOutTooltip": "Zoom Out" + }, + "lightroomDevelop": { + "title": "Photo edits", + "hint": "Review each photo before importing. Supported Lightroom settings are converted to RapidRoom controls; the appearance can differ. Originals and the catalog stay unchanged.", + "reading": "Reading photo edits…", + "selectAvailable": "Select available photos", + "clearSelection": "Clear selection", + "replaceEdits": "Replace saved RapidRoom edits on selected photos", + "importRatings": "Import catalog star ratings", + "replaceRatings": "Replace saved RapidRoom ratings on selected photos", + "preserved": "Saved RapidRoom edits and ratings are preserved unless you choose to replace them.", + "cardMode": "Card mode is read-only. Copy the photos to a local folder before importing edits.", + "selected": "Selected: {{selected}} of {{total}} photos", + "apply": "Import selected photo edits", + "details": "Changes to import", + "unsupported": "Not transferred: {{items}}", + "existing": "Saved RapidRoom metadata", + "newPhoto": "New photo", + "virtualCopy": "Virtual copy", + "rating": "Catalog rating: {{rating}} stars", + "noRating": "No catalog rating", + "missing": "Photo not found", + "previous": "Previous", + "next": "Next", + "page": "Page {{page}} of {{total}}", + "empty": "This catalog has no photo edits to import.", + "result": "Imported: {{imported}}. Preserved: {{preserved}}. New virtual copies: {{virtualCopies}}.", + "noAdjustments": "No supported adjustments.", + "fields": { + "whiteBalance": "white balance without an as-shot reference", + "profileLook": "profile color and lookup tables", + "cameraProfile": "camera profile", + "masks": "local masks", + "aiMasks": "AI masks", + "lensProfile": "Adobe lens profile", + "pointColor": "point color", + "orientationAndCrop": "unsupported orientation and crop", + "virtualCopyName": "virtual-copy name (shown here only)" + }, + "refresh": "Refresh preview" } }, "settings": { From 57b1453f2564c873dfae858891385769e48a7cd1 Mon Sep 17 00:00:00 2001 From: Yojen <86065973+yojen7@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:08:40 -1000 Subject: [PATCH 6/7] Expose visibility controls to keyboard and screen-reader users Give the shared eye button a translated accessible label and pressed state; reveal focused eyes in subsection and section headers. Co-authored-by: marclaliberte --- src/components/adjustments/AdjustmentSubSection.tsx | 2 +- src/components/ui/CollapsibleSection.tsx | 4 +++- src/components/ui/VisibilityToggle.tsx | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/adjustments/AdjustmentSubSection.tsx b/src/components/adjustments/AdjustmentSubSection.tsx index 4ebd2f05a..f00678bc2 100644 --- a/src/components/adjustments/AdjustmentSubSection.tsx +++ b/src/components/adjustments/AdjustmentSubSection.tsx @@ -68,7 +68,7 @@ export default function AdjustmentSubSection({ Date: Sun, 4 Oct 2026 03:25:00 -1000 Subject: [PATCH 7/7] Regenerate status docs after catalog and color-tool integration --- .github/README.md | 2 +- CHANGES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/README.md b/.github/README.md index ab57e1b39..e7d7fa849 100644 --- a/.github/README.md +++ b/.github/README.md @@ -31,7 +31,7 @@ RapidRAW is a free, open-source raw editor by [Timon Käch (CyberTimon)](https:/ - **Pick and reject flags for culling,** separate from stars: P, X and U as in Lightroom, a flag filter, and rejects that darktable, Bridge and Lightroom understand. - **Presets at your fingertips.** Presets sit in the left sidebar as compact rows: hover to preview one on your photo, click to apply, and star favorites to pin them on top. -53 improvements on top of RapidRAW so far, including fixes for 18 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). +54 improvements on top of RapidRAW so far, including fixes for 18 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). ## Why RapidRoom diff --git a/CHANGES.md b/CHANGES.md index 9ad5c0334..dbd39eba3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,7 +4,7 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber Generated from [rapidroom/changes.json](rapidroom/changes.json) by `node rapidroom/status.mjs`; don't edit by hand. -**53 changes on top of RapidRAW.** 19 fix upstream issues that had been open a median of 63 days when RapidRoom shipped the fix; 18 of them still open upstream. 14 offered upstream as PRs, 3 merged so far. +**54 changes on top of RapidRAW.** 19 fix upstream issues that had been open a median of 63 days when RapidRoom shipped the fix; 18 of them still open upstream. 14 offered upstream as PRs, 3 merged so far. | Change | Type | By | Upstream | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |